Compare commits

..
Author SHA1 Message Date
dijunkun 2279636829 [build] bump CLI version to 0.1.2 2026-10-31 01:16:36 +08:00
dijunkun 84270fe065 [fix] remove hardcoded MiniRTC revision check from CLI setup 2026-10-31 00:36:27 +08:00
dijunkun 915cfe3a04 [feat] add live FPS to CLI status bar 2026-10-31 00:36:27 +08:00
dijunkun d687c0e2ea [perf] adapt CLI preview rate and improve frame pacing 2026-10-31 00:36:27 +08:00
dijunkun 5f2caee0cb [build] update iOS project settings for Xcode 27.1 2026-10-31 00:36:27 +08:00
dijunkun e2cd4959c6 [perf] accelerate Kitty and ANSI rendering 2026-10-31 00:36:27 +08:00
dijunkun cd5e1c95d7 [fix] stop iOS file transfers after read or seek failures 2026-10-31 00:36:27 +08:00
dijunkun dde5b57165 [fix] update MiniRTC to prevent macOS connection hangs 2026-10-31 00:18:01 +08:00
dijunkun bcfc8947c9 [fix] resolve iOS deprecation and archive warnings 2026-10-30 21:56:07 +08:00
dijunkun 12ca90d827 [build] bump iOS version to 1.7.4 2026-10-30 21:43:45 +08:00
dijunkun 765e088f70 [build] configure iOS signing and Xcode Cloud dependencies 2026-10-30 21:37:06 +08:00
dijunkun 8f54be6c70 [ui] update iOS local network permission wording 2026-10-30 21:14:04 +08:00
dijunkun 3ff87d8195 [build] bump CLI version to 0.1.1 2026-10-30 18:27:13 +08:00
dijunkun 7ce974f3d4 [build] integrate scoped CLI publishing with GitHub Actions 2026-10-30 17:48:57 +08:00
dijunkun cf039b8f91 [fix] preserve CLI display aspect ratio across terminals 2026-10-30 17:15:47 +08:00
dijunkun 15092b16d6 [fix] remove deprecated Node.js 20 actions from CLI builds 2026-10-30 16:39:12 +08:00
dijunkun de1a663ab4 [fix] improve CLI mouse reporting and click positioning 2026-10-30 16:38:34 +08:00
dijunkun 7b18937df0 [build] cache CLI toolchain and native dependencies 2026-10-30 16:32:46 +08:00
dijunkun c5ee7d0691 [ui] improve CLI connection status and shortcut layout 2026-10-30 16:21:56 +08:00
dijunkun d984aa55de [build] move CLI platform packages to chunkdash scope 2026-10-30 15:58:27 +08:00
dijunkun e6e7e4c0f0 [feat] improve CLI commands, controls and npm packaging 2026-10-30 13:32:34 +08:00
dijunkun 5d0a8f857f [feat] improve CLI shortcuts and local command controls 2026-10-30 11:22:40 +08:00
dijunkun 699d867956 [feat] integrate CrossDesk CLI with terminal rendering and npm packaging 2026-10-30 10:42:23 +08:00
dijunkun 2f57d0aba3 [fix] repair small macOS application icons 2026-10-30 00:58:55 +08:00
dijunkun 68f2d903a3 [fix] restore macOS permission resets during installation 2026-10-30 00:56:06 +08:00
dijunkun becd1c0437 [fix] prevent dropdown items from overlapping the scrollbar 2026-10-30 00:18:52 +08:00
dijunkun 2803892de5 [fix] preserve macOS permissions during installation 2026-10-29 23:32:07 +08:00
dijunkun 0f0dde8b49 [feat] add web clipboard synchronization, refs #132 2026-10-29 23:17:06 +08:00
dijunkun 38a01b085a [fix] update MiniRTC to fix Opus dependency downloads 2026-10-29 21:48:57 +08:00
dijunkun 4190f8beda [fix] update MiniRTC to fix Windows ASIO compilation 2026-10-29 21:07:20 +08:00
dijunkun cc339282d6 Merge branch 'lan-direct' of https://github.com/kunkundi/crossdesk into lan-direct 2026-10-29 20:19:21 +08:00
dijunkun 7c6e14989f [feat] add IPv6 support for direct IP connections, refs #129 2026-10-29 20:02:43 +08:00
dijunkun f09a4c50d1 [feat] add configurable all-interface IPv4 listening, refs #129 2026-10-29 18:23:34 +08:00
dijunkun 8e007b0130 [fix] release idle audio playback devices to allow Windows sleep, refs #131 2026-10-29 15:08:56 +08:00
dijunkun dd774de4f8 [fix] add CI signing keychain to the user search list 2026-10-29 13:57:52 +08:00
dijunkun 613b58f582 [build] add macOS signing and notarization to GitHub Actions 2026-10-29 11:09:12 +08:00
dijunkun 199d29a6dd [fix] restore remote cursors on Linux without a pointing device, refs #130 2026-10-29 10:43:53 +08:00
dijunkun 273cbc0adf [fix] restore null-handle cursors on Windows without a mouse, refs #130 2026-10-29 10:15:32 +08:00
kunkundi d989df275b [fix] fix Windows privacy cover build and adjust branding spacing 2026-10-28 11:35:49 +08:00
dijunkun af49d5ba28 [ui] center privacy unlock hints and update corner branding 2026-10-28 11:03:50 +08:00
dijunkun e922bdc864 [ui] add horizontal privacy screen branding with bottom alignment 2026-10-28 10:34:04 +08:00
dijunkun 37cc6954a2 [ui] hide privacy screen hints when local unlock is disabled 2026-10-28 09:39:42 +08:00
dijunkun 25fb6ee051 [fix] dismiss stale recent connection tooltips and update popup tests 2026-10-27 17:46:43 +08:00
dijunkun 20ede65038 [ui] unify floating logo hand cursors with arrow fallback 2026-10-27 17:07:03 +08:00
dijunkun bfb2e4a84e [fix] install Python build tools for iOS CI 2026-10-27 15:55:02 +08:00
dijunkun 99bb63f641 [fix] fix iOS native builds and expose CI diagnostics 2026-10-27 15:21:32 +08:00
dijunkun 4506c27e7c [fix] fix Windows privacy guard and iOS host tool selection 2026-10-27 13:00:34 +08:00
dijunkun b07f457e76 [feat] add system language support across desktop and mobile 2026-10-27 12:35:47 +08:00
dijunkun b4acf0b597 [build] align mobile Xmake repository selection with desktop 2026-10-27 12:35:11 +08:00
dijunkun 9e7848b9dd [ui] remove trailing colons from settings labels in all languages 2026-10-27 10:36:08 +08:00
dijunkun ea3a2e1faa [feat] add privacy unlock controls and remote relocking 2026-10-27 10:22:25 +08:00
dijunkun d1e4a31efb [ui] unify mobile privacy screen icons by state 2026-10-27 04:30:01 +08:00
dijunkun a6658452c2 [ui] remove network summaries from mobile control headers 2026-10-27 04:23:20 +08:00
dijunkun e123d5f2cc [feat] add mobile codec status and scrolling codec names 2026-10-27 04:16:41 +08:00
dijunkun 1687816bde [fix] show shortcut controls only for Windows hosts 2026-10-27 04:01:39 +08:00
dijunkun a9e3178042 [fix] make mobile landscape locking opt-in 2026-10-27 03:55:17 +08:00
dijunkun 0e052e6d7a [fix] hide privacy controls on locked desktops across clients 2026-10-27 03:46:19 +08:00
dijunkun 922cd41f70 [docs] clarify Windows service updates for development builds 2026-10-27 03:07:55 +08:00
dijunkun b15925f857 [feat] add mobile privacy controls and Windows secure text input 2026-10-27 02:37:23 +08:00
dijunkun 1e25ec15e1 [fix] support hotfix versions in Android signing 2026-10-26 17:50:11 +08:00
dijunkun 9f7411e5c0 [fix] update MiniRTC for WebSocket++ C++20 compatibility 2026-10-26 16:38:15 +08:00
dijunkun 8fd7ca1e8d [fix] correct Windows LAN identity file ownership 2026-10-26 15:52:21 +08:00
dijunkun c3ea4eb8fc [ui] flatten desktop surfaces and controls 2026-10-26 14:53:09 +08:00
dijunkun 38d0ce7728 [build] remove license validation from mobile builds 2026-10-26 13:12:11 +08:00
dijunkun 778c3575cd [ui] refine desktop controls and align localized copy 2026-10-26 13:00:03 +08:00
dijunkun dad8384666 [docs] add README banner and clarify license metadata 2026-10-26 11:36:36 +08:00
dijunkun 9496bc8f1c [docs] update README screenshots for refreshed desktop UI 2026-10-26 11:23:02 +08:00
dijunkun e551e04e8f [ui] refine desktop shadows and control contrast 2026-10-26 11:18:41 +08:00
dijunkun e3b3f78b06 [fix] invalidate stale Slint build artifacts after UI changes 2026-10-26 10:29:12 +08:00
dijunkun 5db213d126 [ui] replace connection mode button with a sliding toggle 2026-10-26 10:16:17 +08:00
dijunkun bdd04603c7 [feat] enable screen lock on disconnect by default 2026-10-25 03:07:13 +08:00
dijunkun f3d8689b55 [fix] allow alphanumeric password input on iOS, fixes #127 2026-10-25 03:01:45 +08:00
dijunkun 0fb4b031c7 [fix] adapt recent connection thumbnails to display DPI 2026-10-25 02:57:33 +08:00
dijunkun 7fc21f01bc [ui] add a compact edit button for self-hosted settings 2026-10-25 02:43:24 +08:00
dijunkun 86bae6e1f4 [ui] remove TURN service toggle and simplify relay settings 2026-10-25 02:40:21 +08:00
dijunkun c03c8a63d4 [fix] avoid Windows interface macro collision 2026-10-25 02:32:42 +08:00
dijunkun ec9ac6a7cd [chore] simplify issue templates and reduce required fields 2026-10-25 01:51:48 +08:00
dijunkun 2a0b14035d [fix] preserve Unicode device names, fixes #126 2026-10-25 01:47:10 +08:00
dijunkun 652378afa8 [fix] add missing stream header and update libnice recipe checksum 2026-10-25 01:39:49 +08:00
dijunkun 0cbdbb0c96 [feat] add lock screen on disconnect option, refs #125 2026-10-25 01:35:40 +08:00
dijunkun 63bc5648c0 [chore] update MiniRTC for IP direct API grouping 2026-10-25 01:24:39 +08:00
dijunkun 859aaa29d4 [docs] document ID and direct IP connection architecture 2026-10-25 01:19:04 +08:00
dijunkun cc48eb651f [docs] document IP direct mode and refresh desktop screenshots 2026-10-25 01:05:43 +08:00
dijunkun ae6f81e83a [feat] integrate IP direct mode with desktop controls and history 2026-10-25 00:54:13 +08:00
dijunkun 2445f79f92 [feat] add authenticated LAN transport and per-IP listeners 2026-10-25 00:54:10 +08:00
dijunkun bd7f9505d9 [docs] document update public key build option 2026-10-24 21:03:55 +08:00
dijunkun 4650e48ea3 [feat] add configurable landscape entry for mobile sessions 2026-10-24 03:28:23 +08:00
dijunkun 182899d238 [fix] scope sleep inhibition to active screen sharing, refs #123 2026-10-24 01:24:38 +08:00
dijunkun 1d465239cf [chore] update MiniRTC for web file transfers 2026-10-23 23:01:39 +08:00
dijunkun 80ea75ff78 [ui] highlight disconnect buttons and confirm session closure 2026-10-23 14:19:18 +08:00
dijunkun ff7b954949 [ui] add animated logo toolbar and disconnect confirmation 2026-10-23 13:56:25 +08:00
dijunkun 0fbefd309f [fix] preserve remote mouse gestures across control overlays 2026-10-23 10:11:01 +08:00
dijunkun f269c56e5b [fix] unify network statistics resolution formatting 2026-10-22 19:30:54 +08:00
dijunkun c6a2f6a35b [feat] display video codecs and refine network statistics 2026-10-22 19:18:37 +08:00
dijunkun 193dcc4be0 [feat] prefer hardware video codecs by default 2026-10-22 17:20:48 +08:00
dijunkun f8f4aa3cea [fix] prevent remote input from blocking transport callbacks 2026-10-22 16:10:58 +08:00
dijunkun c25e7266e5 [fix] retain failed keyboard releases per controller 2026-10-22 16:10:44 +08:00
dijunkun 5e494bfa3b [fix] support selective mouse button release across desktop platforms 2026-10-22 16:10:34 +08:00
dijunkun f6a2637540 [fix] send mouse movement over unreliable transport 2026-10-22 10:59:36 +08:00
kunkundi 621f2ff205 [fix] update MiniRTC for Windows hardware encoding 2026-10-22 10:46:24 +08:00
dijunkun 5ac6e153c8 [fix] preserve Windows remote sessions during shutdown 2026-10-22 04:17:27 +08:00
dijunkun dd5c572852 [feat] enable Windows MFT codecs without CUDA 2026-10-22 03:25:24 +08:00
dijunkun 33d4199f7c [ui] turn the reconnect action into a button 2026-10-21 23:38:32 +08:00
dijunkun a6f4df405b [fix] update MiniRTC for signaling disconnect detection 2026-10-21 23:34:53 +08:00
dijunkun 97960f3a6b [ui] remove icons from about navigation rows 2026-10-21 23:34:44 +08:00
dijunkun 53c657aa55 [fix] share desktop build version across workflow jobs 2026-10-21 23:18:23 +08:00
dijunkun 941de898fb [fix] validate desktop artifact versions against release tags 2026-10-21 23:10:14 +08:00
dijunkun 6abcafc205 [fix] prevent older releases from overwriting update metadata 2026-10-21 23:03:32 +08:00
dijunkun 171e559b0d [feat] add desktop license and source information pages 2026-10-21 11:45:27 +08:00
dijunkun 41f1e29583 [fix] pin desktop and Python dependencies to OpenSSL 3.3.2 2026-10-21 11:07:16 +08:00
dijunkun 2ea462764d [fix] clear confirmed update receipts before temporary cleanup 2026-10-21 03:17:20 +08:00
dijunkun ef02ef2e6c [fix] preserve Linux desktop options when restarting after updates 2026-10-21 03:16:23 +08:00
dijunkun e45350319c [fix] authorize POSIX updates before closing CrossDesk 2026-10-21 03:10:40 +08:00
dijunkun cf98775754 [fix] secure Linux credential cache permissions 2026-10-21 02:08:31 +08:00
dijunkun 1aa23126d2 [fix] use in-app confirmation for update installation 2026-10-21 00:21:59 +08:00
dijunkun cb2ae207c4 [fix] use numeric versions for desktop branch builds 2026-10-20 21:14:38 +08:00
dijunkun a625890b28 [feat] add in-app updates for desktop installations 2026-10-20 20:11:12 +08:00
dijunkun 62ba3693af [fix] restore clipboard sync after focus returns, refs #121 2026-10-20 11:34:24 +08:00
dijunkun 4e5bf66734 [fix] support Unicode text input on Windows hosts, refs #120 2026-10-20 11:22:14 +08:00
dijunkun 086da155f0 [feat] add website update button to about dialog 2026-10-19 23:27:12 +08:00
dijunkun 8a7de23c7f [feat] show sponsorship prompt once per version 2026-10-19 23:06:14 +08:00
dijunkun e7a49361f7 [feat] add a one-time sponsorship prompt 2026-10-19 17:39:50 +08:00
dijunkun 9661d56ed5 [fix] remove conflicting update dialog height constraints 2026-10-19 11:47:50 +08:00
dijunkun 28451e3e03 [fix] unify update and about dialog geometry across platforms 2026-10-19 11:39:42 +08:00
dijunkun 44f19a595b [fix] keep about dialog layout stable during update checks 2026-10-19 11:29:19 +08:00
dijunkun 9a4661e327 [fix] allow controlled devices to queue files during transfers 2026-10-19 11:16:12 +08:00
dijunkun 7df0b6118e [feat] add file transfer cancellation controls 2026-10-19 11:11:00 +08:00
dijunkun b2d3834944 [feat] unify persistent file transfer notifications 2026-10-19 10:53:55 +08:00
dijunkun 7662b80995 [chore] update MiniRTC documentation reference 2026-10-19 04:55:02 +08:00
dijunkun 0b7273d79d [chore] replace issue templates with bilingual forms 2026-10-19 04:45:36 +08:00
dijunkun 695dacf87a [docs] improve README guides and refresh desktop screenshots 2026-10-19 04:39:28 +08:00
dijunkun 1ef372d27c [docs] refine README badge layout and styling 2026-10-19 04:30:09 +08:00
dijunkun 19bbd3b596 [docs] add separate sponsorship badges for each platform 2026-10-19 04:25:21 +08:00
dijunkun 1b21bb8640 [chore] add repository sponsorship links 2026-10-19 04:16:12 +08:00
dijunkun fc5fe9ac61 [feat] add sponsorship links to about and update dialogs 2026-10-19 04:12:14 +08:00
dijunkun a7adf342d7 [ui] clarify feature and performance benefits in remote update notice 2026-10-19 02:02:04 +08:00
dijunkun c8d03d9a5e [ui] simplify remote update dialog layout 2026-10-19 01:58:54 +08:00
dijunkun d8fe1053c2 [ui] refine device tooltips with compact adaptive cards 2026-10-19 01:53:57 +08:00
dijunkun fe93c7e0e4 [ui] refine recent connection card actions 2026-10-19 01:43:52 +08:00
dijunkun c61cf176cb [fix] limit remote ID input to nine digits without flicker 2026-10-19 01:21:35 +08:00
dijunkun 65a5288986 [ui] reorder mouse controls in mobile floating menus 2026-10-18 22:16:32 +08:00
dijunkun 36f7213162 [feat] allow pinch zoom outside the remote image on mobile 2026-10-18 22:09:35 +08:00
dijunkun acd56dfc29 [feat] add vertical pointer edge panning on mobile 2026-10-18 22:02:17 +08:00
dijunkun b948422bfe [feat] add Android system keyboard input and improve computer keyboard, refs #118 2026-10-18 21:20:37 +08:00
dijunkun 42856be8b8 [feat] add iOS system keyboard input and improve computer keyboard 2026-10-18 20:15:45 +08:00
dijunkun 02c3952be0 [feat] activate existing window on duplicate launches, refs #119 2026-10-18 14:17:26 +08:00
dijunkun 34eb2b1fe3 [chore] update MiniRTC for web ICE connectivity 2026-10-17 15:24:42 +08:00
dijunkun 0901d61533 [fix] restore Windows remote control after RDP disconnects 2026-10-16 18:20:45 +08:00
dijunkun 40670de954 [fix] accept web video settings and update MiniRTC QoS 2026-10-16 13:44:24 +08:00
dijunkun 33cbfa5b8a [chore] update MiniRTC for web video settings negotiation 2026-10-16 10:57:54 +08:00
dijunkun 81430d78d9 [chore] update MiniRTC for web quality fixes and 30fps cap 2026-10-16 10:12:38 +08:00
dijunkun aed6ad99c2 [chore] update MiniRTC for web QoS flow control 2026-10-16 09:40:58 +08:00
dijunkun 1e2520d472 [fix] update mobile license catalog recipe checksums 2026-10-16 01:14:11 +08:00
kunkundi 697843cccd [docs] document HarmonyOS support and project copyright 2026-10-16 00:53:54 +08:00
kunkundi 45bf6ec4ee [feat] add HarmonyOS desktop and mobile clients,issue #117 2026-10-16 00:42:55 +08:00
kunkundi 43a0789c1e [refactor] share mobile controller protocol and video timing 2026-10-16 00:40:07 +08:00
kunkundi c4756b298b [refactor] share file transfer core across desktop and mobile 2026-10-16 00:38:13 +08:00
dijunkun 47e6385f9a [ui] refine floating notices and dismiss them on interaction 2026-10-14 23:40:15 +08:00
dijunkun 9f610bfc9b [ui] update macOS permission dialog to match desktop styling 2026-10-14 22:56:29 +08:00
dijunkun 006526389a [docs] update README for DeskHub and refresh desktop screenshots 2026-10-14 21:06:06 +08:00
dijunkun e76d2bf903 [ui] unify settings dialogs and improve announcement controls 2026-10-14 20:18:51 +08:00
kunkundi 2cf0d79543 [ui] refine desktop layouts and unify control styling 2026-10-14 19:29:08 +08:00
dijunkun 3043b5f110 [fix] follow the selected device cursor in DeskHub 2026-10-14 10:20:31 +08:00
dijunkun 5721222724 [fix] disable already added devices in DeskHub recent picker 2026-10-14 10:20:20 +08:00
dijunkun 3ee0d42494 [feat] add DeskHub group control 2026-10-14 10:20:05 +08:00
dijunkun 417ef7d58c [fix] disconnect remote sessions when closing DeskHub 2026-10-14 04:17:44 +08:00
dijunkun 0a6356085d [feat] show video latency in DeskHub network status 2026-10-14 04:17:38 +08:00
dijunkun 61ff9b2787 [feat] add resizable DeskHub focus previews 2026-10-14 04:17:31 +08:00
dijunkun 4855b2d3b6 [fix] preserve DeskHub previews and control during fullscreen changes 2026-10-14 04:17:23 +08:00
dijunkun 2ac197c2de [ui] streamline DeskHub toolbar layout and buttons 2026-10-14 04:17:17 +08:00
dijunkun b90359b08d [fix] sync DeskHub previews with tile size and unify tooltips 2026-10-14 02:58:08 +08:00
dijunkun 5199b2df11 [feat] add standalone DeskHub window and resizable device table 2026-10-14 02:37:06 +08:00
dijunkun fd86dba212 [fix] preserve remote cursors when macOS hides the pointer, refs #116 2026-10-13 21:49:06 +08:00
dijunkun 2a9887ab8a [fix] fix UPnP leaks and disable UPnP on iOS, refs #115 2026-10-13 20:42:57 +08:00
dijunkun 435feb419c [ui] simplify DeskHub controls and unify device status styling 2026-10-13 18:12:57 +08:00
dijunkun 4e394ca650 [ui] place DeskHub thumbnails above the focused desktop and compact labels 2026-10-13 16:11:27 +08:00
dijunkun 4dfd8c3005 [feat] allocate DeskHub video bandwidth automatically 2026-10-13 15:39:50 +08:00
dijunkun de16eb3b92 [refactor] use MiniRTC APIs for DeskHub media pause controls 2026-10-13 15:39:34 +08:00
dijunkun 3bd0676672 [feat] add DeskHub stream controls and network status 2026-10-13 10:45:48 +08:00
dijunkun 73b4d0f645 [feat] add DeskHub device wall and shared group actions 2026-10-12 03:08:04 +08:00
dijunkun 54dbd04a8d [fix] isolate background connections and password retries 2026-10-12 03:08:00 +08:00
dijunkun c8907a00f9 [feat] negotiate per-device preview and audio subscriptions 2026-10-12 03:07:10 +08:00
dijunkun c9e0078bfb [fix] persist successful connections without saved passwords 2026-10-12 03:06:33 +08:00
dijunkun e156ff5277 [fix] handle Markdown separators in release notes 2026-10-12 03:06:18 +08:00
dijunkun b44a203b19 [fix] update MiniRTC with DTLS retransmission recovery 2026-10-12 03:06:03 +08:00
538 changed files with 68265 additions and 7380 deletions
+3
View File
@@ -0,0 +1,3 @@
github: kunkundi
ko_fi: kunkundi
custom: ["https://afdian.com/a/kunkundi"]
+38
View File
@@ -0,0 +1,38 @@
name: 问题反馈 / Bug report
description: 报告 CrossDesk 的错误或异常行为。Report a bug or unexpected behavior in CrossDesk.
title: "[Bug] "
labels: [bug]
assignees: [kunkundi]
body:
- type: markdown
attributes:
value: |
只需描述问题,其余选填。中文或英文均可。
Only a problem description is required. Chinese or English is welcome.
- type: textarea
id: problem
attributes:
label: 问题描述 / Problem description
description: 遇到了什么问题?如方便,也请说明触发操作或错误提示。What went wrong? If possible, include what you were doing or any error message.
validations:
required: true
- type: input
id: environment
attributes:
label: 使用环境(选填) / Environment (optional)
description: 操作系统和 CrossDesk 版本;连接问题可填写两端信息。OS and CrossDesk version; include both devices for connection issues.
placeholder: Windows 11 → macOS 15; CrossDesk x.y.z
validations:
required: false
- type: textarea
id: attachments
attributes:
label: 截图或日志(选填) / Screenshots or logs (optional)
description: |
可直接拖入截图、录屏或日志文件,请隐去密码等敏感信息。
Drag in screenshots, recordings, or logs. Hide passwords and other sensitive information.
validations:
required: false
@@ -0,0 +1,27 @@
name: 需求建议 / Feature request
description: 提出 CrossDesk 的新功能或改进建议。Suggest a new feature or improvement for CrossDesk.
title: "[Feature] "
labels: [enhancement]
assignees: [kunkundi]
body:
- type: markdown
attributes:
value: |
只需描述你的建议,其余选填。中文或英文均可。
Only a description of your idea is required. Chinese or English is welcome.
- type: textarea
id: proposal
attributes:
label: 你的建议 / Your idea
description: 希望新增或改进什么?如方便,也请说明使用场景。What would you like to add or improve? If possible, describe when you would use it.
validations:
required: true
- type: textarea
id: references
attributes:
label: 补充说明(选填) / Additional context (optional)
description: 可附截图、参考链接或其他说明。Add screenshots, links, or any other details.
validations:
required: false
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 常见问题 / FAQ
url: https://github.com/kunkundi/crossdesk/blob/deskhub/docs/FAQ.md
about: 连接、黑屏、系统权限与安装排查。Troubleshoot connections, blank video, permissions, and installation.
- name: 使用指南(中文)
url: https://github.com/kunkundi/crossdesk/blob/deskhub/docs/USER_GUIDE.md
about: 查看连接、桌面墙、移动端、无人值守和自托管的使用说明。
- name: User guide (English)
url: https://github.com/kunkundi/crossdesk/blob/deskhub/docs/USER_GUIDE_EN.md
about: Read about connections, DeskHub, mobile clients, unattended access, and self-hosting.
-35
View File
@@ -1,35 +0,0 @@
---
name: 问题反馈
about: 请在此提交问题报告,以便持续优化产品。
title: ''
labels: bug
assignees: kunkundi
---
**描述问题**
清晰简洁地描述遇到的错误。
**复现步骤**
复现该问题的步骤:
1. 前往 '...'
2. 点击 '....'
3. 出现错误
**预期行为**
清晰简洁地描述你期望发生的行为。
**截图**
如果适用,请添加截图以帮助说明问题。
**桌面端信息(请填写以下内容):**
- 操作系统: [例如 Windows 11]
- 版本: [例如 v1.1.10]
**移动端信息(请填写以下内容):**
- 设备: [例如 iPhone 17]
- 操作系统: [例如 iOS 26.1]
- 浏览器: [例如 系统浏览器、Safari]
**补充信息**
在此添加与问题相关的其他上下文内容。
-28
View File
@@ -1,28 +0,0 @@
---
name: 需求建议
about: 请在此提交功能需求或改进建议,以便后续迭代参考。
title: ''
labels: enhancement
assignees: kunkundi
---
**功能/改进建议描述**
清晰简洁地描述希望新增的功能或改进的内容。
**使用场景 / 背景**
说明该功能或改进的使用场景,以及解决后带来的价值。
**预期效果**
描述你认为最理想的功能表现或改进效果。
**参考示例(可选)**
提供类似功能截图、参考链接或其他说明,帮助更好理解需求。
**优先级(可选)**
- [ ] 高
- [ ] 中
- [ ] 低
**补充信息(可选)**
其他相关信息或特殊要求。
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
# Never echo commands containing imported certificate or notarization secrets.
set +x
if [[ "${GITHUB_ACTIONS:-}" != "true" ]]; then
echo "This script prepares an isolated GitHub Actions signing keychain." >&2
exit 1
fi
: "${RUNNER_TEMP:?RUNNER_TEMP is required}"
: "${GITHUB_ENV:?GITHUB_ENV is required}"
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}"
for secret_name in MACOS_APP_CERTIFICATE_P12_BASE64 MACOS_INSTALLER_CERTIFICATE_P12_BASE64 \
MACOS_P12_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [[ -z "${!secret_name:-}" ]]; then
echo "Missing required Actions secret: $secret_name" >&2
exit 1
fi
done
if [[ ! "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then
echo "APPLE_TEAM_ID must contain 10 uppercase letters or digits." >&2
exit 1
fi
SIGNING_DIR="$RUNNER_TEMP/crossdesk-macos-signing"
KEYCHAIN_PATH="$SIGNING_DIR/signing.keychain-db"
APP_P12="$SIGNING_DIR/application.p12"
INSTALLER_P12="$SIGNING_DIR/installer.p12"
NOTARY_PROFILE="crossdesk-notary"
# Refuse to overwrite anything left by an unrelated task. Each hosted matrix
# runner has its own RUNNER_TEMP, and the workflow also cleans up with always().
umask 077
mkdir "$SIGNING_DIR"
cleanup_setup() {
local status=$?
rm -f "$APP_P12" "$INSTALLER_P12"
if [[ "$status" -ne 0 ]]; then
security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true
rm -rf "$SIGNING_DIR"
fi
exit "$status"
}
trap cleanup_setup EXIT
printf '%s' "$MACOS_APP_CERTIFICATE_P12_BASE64" | base64 --decode > "$APP_P12"
printf '%s' "$MACOS_INSTALLER_CERTIFICATE_P12_BASE64" | base64 --decode > "$INSTALLER_P12"
KEYCHAIN_PASSWORD="$(openssl rand -hex 32)"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# codesign requires the identity's keychain on the user's search list even
# when --keychain is supplied. Preserve the runner's existing keychains so
# certificate-chain lookup continues to work. Deleting our temporary keychain
# during cleanup also removes its entry from the search list.
EXISTING_KEYCHAINS="$(security list-keychains -d user)"
KEYCHAIN_SEARCH_LIST=()
while IFS= read -r existing_keychain; do
[[ -n "$existing_keychain" && "$existing_keychain" != "$KEYCHAIN_PATH" ]] || continue
KEYCHAIN_SEARCH_LIST+=("$existing_keychain")
done <<< "$(printf '%s\n' "$EXISTING_KEYCHAINS" | sed -E 's/^[[:space:]]*"(.*)"[[:space:]]*$/\1/')"
security list-keychains -d user -s "$KEYCHAIN_PATH" \
${KEYCHAIN_SEARCH_LIST[@]+"${KEYCHAIN_SEARCH_LIST[@]}"}
for certificate in "$APP_P12" "$INSTALLER_P12"; do
security import "$certificate" -P "$MACOS_P12_PASSWORD" \
-t cert -f pkcs12 -k "$KEYCHAIN_PATH" \
-T /usr/bin/codesign -T /usr/bin/productbuild -T /usr/bin/productsign
done
# Allow the signing tools to use only this job's imported keys without a GUI
# password prompt. The user's login keychain and its access controls are untouched.
security set-key-partition-list -S apple-tool:,apple:,codesign: -s \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
find_identity() {
local policy="$1"
local prefix="$2"
security find-identity -v -p "$policy" "$KEYCHAIN_PATH" | \
awk -F '"' -v prefix="$prefix" -v suffix=" ($APPLE_TEAM_ID)" '
index($2, prefix) == 1 &&
substr($2, length($2) - length(suffix) + 1) == suffix {
print $2
count++
}
END { if (count != 1) exit 1 }'
}
if ! APP_IDENTITY="$(find_identity codesigning 'Developer ID Application: ')"; then
echo "Expected one valid Developer ID Application identity for APPLE_TEAM_ID, including its private key." >&2
exit 1
fi
if ! INSTALLER_IDENTITY="$(find_identity basic 'Developer ID Installer: ')"; then
echo "Expected one valid Developer ID Installer identity for APPLE_TEAM_ID, including its private key." >&2
exit 1
fi
xcrun notarytool store-credentials "$NOTARY_PROFILE" \
--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" --keychain "$KEYCHAIN_PATH" --validate
{
printf 'CROSSDESK_MACOS_KEYCHAIN=%s\n' "$KEYCHAIN_PATH"
printf 'CROSSDESK_MACOS_SIGN_IDENTITY=%s\n' "$APP_IDENTITY"
printf 'CROSSDESK_MACOS_INSTALLER_IDENTITY=%s\n' "$INSTALLER_IDENTITY"
} >> "$GITHUB_ENV"
# Keep notarization separate from packaging so CI can retain a signed package
# and the submission ID when Apple's processing exceeds the wait timeout.
printf 'notary_profile=%s\n' "$NOTARY_PROFILE" >> "$GITHUB_OUTPUT"
echo "Prepared Developer ID application and installer identities for team $APPLE_TEAM_ID."
+108 -14
View File
@@ -6,6 +6,10 @@ import datetime
import json
from pathlib import Path
import re
import hashlib
import os
import subprocess
import tempfile
DOWNLOAD_BASE = "https://downloads.crossdesk.cn/"
@@ -22,8 +26,31 @@ ARTIFACTS = {
"android-arm64": ("android-arm64(?:-unsigned)?", "apk"),
}
def sign_update(payload, private_key, expected_public_key):
"""Sign exact UTF-8 JSON bytes using a PEM Ed25519 key, never shell text."""
if not re.fullmatch(r"[0-9a-f]{64}", expected_public_key):
raise ValueError("CROSSDESK_UPDATE_PUBLIC_KEY must contain 64 lowercase hex characters")
with tempfile.TemporaryDirectory(prefix="crossdesk-sign-") as directory:
root = Path(directory)
key = root / "key.pem"
key.write_text(private_key, encoding="utf-8")
key.chmod(0o600)
public = subprocess.run(["openssl", "pkey", "-in", str(key), "-pubout", "-outform", "DER"],
check=True, capture_output=True).stdout
# RFC 8410 SubjectPublicKeyInfo prefix for an Ed25519 public key.
if public != bytes.fromhex("302a300506032b6570032100" + expected_public_key):
raise ValueError("Update signing key does not match the public key built into clients")
data = root / "payload.json"
data.write_bytes(payload.encode("utf-8"))
signature = subprocess.run(
["openssl", "pkeyutl", "-sign", "-rawin", "-inkey", str(key), "-in", str(data)],
check=True, capture_output=True).stdout
if len(signature) != 64:
raise ValueError("Expected an Ed25519 signature")
return {"payload": payload, "signature": signature.hex()}
def version_info(version, fallback_date=None):
def version_fields(version):
version = version.removeprefix("v")
base = r"[0-9]+(?:\.[0-9]+){1,3}"
for pattern in (
@@ -33,20 +60,45 @@ def version_info(version, fallback_date=None):
):
match = re.fullmatch(pattern, version)
if match:
fields = match.groupdict()
date = fields.get("date")
release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
if date else fallback_date)
if not release_date:
raise ValueError(f"Release date is missing for {version}")
datetime.date.fromisoformat(release_date)
return {"latest_version": version, "version": version,
"releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
return match.groupdict()
raise ValueError(f"Invalid release version: {version}")
def version_info(version, fallback_date=None):
fields = version_fields(version)
version = version.removeprefix("v")
date = fields.get("date")
release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
if date else fallback_date)
if not release_date:
raise ValueError(f"Release date is missing for {version}")
datetime.date.fromisoformat(release_date)
return {"latest_version": version, "version": version,
"releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
def release_order(metadata):
"""Match client ordering: zero-padded numeric components, then patch, never date."""
version = metadata.get("latest_version") or metadata.get("version")
if not version:
return None
fields = version_fields(version)
numbers = tuple(int(part) for part in fields["base"].split("."))
inline_patch = fields.get("patch") or "0"
patch = int(inline_patch) if len(inline_patch) <= 4 else 0
# Legacy manifests may store the effective patch outside the version string.
explicit_patch = metadata.get("patch")
if isinstance(explicit_patch, str) and re.fullmatch(r"[0-9]+", explicit_patch):
explicit_patch = int(explicit_patch)
if type(explicit_patch) is int and 0 <= explicit_patch <= 2147483647:
patch = explicit_patch
return numbers + (0,) * (4 - len(numbers)) + (patch,)
def artifact_downloads(directory):
downloads = {}
private_key = os.environ.get("CROSSDESK_UPDATE_PRIVATE_KEY", "")
public_key = os.environ.get("CROSSDESK_UPDATE_PUBLIC_KEY", "")
for path in sorted(Path(directory).iterdir()):
if not path.is_file():
continue
@@ -65,6 +117,19 @@ def artifact_downloads(directory):
continue
downloads[key] = {"url": DOWNLOAD_BASE + path.name, "filename": path.name,
"version": metadata["version"], "releaseDate": metadata["releaseDate"]}
with path.open("rb") as artifact:
hasher = hashlib.sha256()
for chunk in iter(lambda: artifact.read(65536), b""):
hasher.update(chunk)
digest = hasher.hexdigest()
downloads[key].update(size=path.stat().st_size, sha256=digest)
if private_key and key.startswith(("windows-", "macos-", "linux-")) and "portable" not in key:
payload = json.dumps({
"schema": 1, "platform": key, "version": metadata["version"],
"url": DOWNLOAD_BASE + path.name, "filename": path.name,
"size": path.stat().st_size, "sha256": digest,
}, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
downloads[key]["update"] = sign_update(payload, private_key, public_key)
break
if not downloads:
raise ValueError("No supported release artifacts found")
@@ -87,10 +152,34 @@ def merge_release(current, tag, name, notes, downloads, published_date=None):
if not tag.startswith("v"):
raise ValueError(f"Expected a version tag, got {tag!r}")
metadata = version_info(tag, published_date)
# Clients require the full artifact version to match the advertised
# desktop version, including its date and patch spelling.
for platform, download in downloads.items():
if (platform.startswith(("windows-", "macos-", "linux-"))
and download["version"] != metadata["version"]):
raise ValueError(
f"Desktop release {tag} expects version {metadata['version']}, "
f"but {platform} artifact {download['filename']} "
f"has version {download['version']}")
current_order = release_order(current)
if current_order is not None and release_order(metadata) < current_order:
current_version = current.get("latest_version") or current.get("version")
print(f"Skipping {tag}: older than current desktop release {current_version}")
return dict(current)
accepted_downloads = {}
for platform, download in downloads.items():
previous_order = release_order(current["downloads"].get(platform, {}))
if previous_order is not None and release_order(download) < previous_order:
print(f"Skipping {tag} download for {platform}: a newer version is already published")
continue
accepted_downloads[platform] = download
if mobile and not accepted_downloads:
return dict(current)
result = dict(current)
result.pop("platforms", None)
result["downloads"] = {**current["downloads"], **downloads}
result["downloads"] = {**current["downloads"], **accepted_downloads}
if not mobile:
result.update({**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag})
return result
@@ -117,11 +206,16 @@ def main():
parser.error("Supply --release-json or --tag, --name and --notes")
tag, name, notes = args.tag, args.name, args.notes.read_text()
published_date = None
current = json.loads(args.current.read_text())
current_text = args.current.read_text()
current = json.loads(current_text)
downloads = artifact_downloads(args.artifacts)
result = merge_release(current, tag, name, notes, downloads, published_date)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(f"Updated {tag}: {', '.join(downloads)}")
if result == current:
args.output.write_text(current_text)
print(f"No changes to version.json for {tag}")
else:
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(f"Updated version.json for {tag}")
if __name__ == "__main__":
+2 -12
View File
@@ -36,7 +36,6 @@ on:
- "apps/android/**"
- "apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"
- "apps/ios/licenses/**"
- "apps/ios/scripts/generate_third_party_licenses.py"
- "deps/submodules/minirtc"
- "libs/wire/**"
@@ -135,14 +134,8 @@ jobs:
actions-cache-folder: .xmake-toolchain-cache
actions-cache-key: ${{ runner.os }}-${{ runner.arch }}-ubuntu24.04-android-xmake${{ env.XMAKE_MIN_VERSION }}
- name: Cache Xmake package repository
uses: actions/cache@v5
with:
path: apps/android/.native/global/.xmake/repositories/xmake-repo
key: ${{ runner.os }}-${{ runner.arch }}-android-xmake-repo-v1-${{ hashFiles('apps/android/scripts/build_native.py') }}
# Cache installed packages only. build_native.py checks Xmake >= 3.1.1 and pins
# its own package repository; never reuse another platform's build/config.
# Repository selection follows desktop Xmake behavior. Keep installed
# packages isolated; never reuse another platform's build/config.
- name: Cache native dependencies
uses: actions/cache@v5
with:
@@ -155,9 +148,6 @@ jobs:
cache-provider: basic
cache-read-only: ${{ github.event_name == 'pull_request' }}
- name: Verify shared license catalog
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Resolve Android version
shell: python
env:
+131
View File
@@ -0,0 +1,131 @@
name: CLI native builds and npm packages
on:
push:
branches: ["**"]
tags-ignore: ["cli-v*"]
pull_request:
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
target: darwin-arm64
arch: arm64
- runner: macos-15-intel
target: darwin-x64
arch: x86_64
- runner: ubuntu-22.04
target: linux-x64
arch: x86_64
- runner: ubuntu-22.04-arm
target: linux-arm64
arch: arm64
- runner: windows-2022
target: win32-x64
arch: x64
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
env:
XMAKE_VERSION: "3.1.1"
steps:
- uses: actions/checkout@v5
with:
submodules: recursive
- uses: actions/setup-node@v6
with:
node-version: 22
package-manager-cache: false
# A runner label can receive compiler/SDK updates without changing its name.
- name: Set cache environment
id: runner-image
shell: bash
run: |
printf 'version=%s\n' "${ImageVersion:?Missing GitHub runner image version}" >> "$GITHUB_OUTPUT"
# Keep installed packages separate from the project's build/config state.
printf 'XMAKE_PKG_INSTALLDIR=%s/crossdesk-cli-packages\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- uses: xmake-io/github-action-setup-xmake@v1
with:
xmake-version: ${{ env.XMAKE_VERSION }}
actions-cache-folder: ${{ runner.temp }}/crossdesk-cli-xmake-toolchain
actions-cache-key: cli-${{ matrix.runner }}-${{ matrix.target }}-image${{ steps.runner-image.outputs.version }}-xmake${{ env.XMAKE_VERSION }}
- name: Configure MSVC (x64)
if: runner.os == 'Windows'
shell: pwsh
run: |
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
$vsPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if ($LASTEXITCODE -ne 0 -or -not $vsPath) {
throw 'Visual Studio C++ build tools were not found'
}
$previousEnv = @{}
Get-ChildItem Env: | ForEach-Object { $previousEnv[$_.Name] = $_.Value }
& (Join-Path $vsPath 'Common7\Tools\Launch-VsDevShell.ps1') -Arch amd64 -HostArch amd64 -SkipAutomaticLocation
Get-Command cl.exe -ErrorAction Stop | Out-Null
# Persist the compiler, SDK and PATH settings for subsequent steps.
Get-ChildItem Env: | Where-Object { $previousEnv[$_.Name] -cne $_.Value } | ForEach-Object {
"$($_.Name)=$($_.Value)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
}
- name: Linux build tools
if: runner.os == 'Linux'
shell: bash
run: |
missing=()
for package in build-essential pkg-config autoconf automake libtool nasm yasm zlib1g-dev python3-pip; do
if [[ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" != "install ok installed" ]]; then
missing+=("$package")
fi
done
if (( ${#missing[@]} )); then
sudo apt-get update
sudo apt-get install -y --no-install-recommends "${missing[@]}"
fi
- name: Prepare pinned dependency
run: sh apps/cli/scripts/prepare-deps.sh
# Exact matches only: changed local recipes/patches must not reuse old libraries.
# Source, README and npm metadata edits do not change this key.
- name: Restore native dependencies
id: native-cache
uses: actions/cache/restore@v5
with:
path: ${{ env.XMAKE_PKG_INSTALLDIR }}
key: cli-deps-v1-${{ matrix.runner }}-${{ matrix.target }}-image${{ steps.runner-image.outputs.version }}-xmake${{ env.XMAKE_VERSION }}-${{ hashFiles('xmake.lua', 'apps/cli/xmake.lua', 'apps/desktop/xmake/options.lua', 'deps/thirdparty/openssl/**', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**', 'deps/patches/**', '.github/workflows/build-cli.yml') }}
- name: Configure macOS
if: runner.os == 'macOS'
run: xmake f -y -p macosx -a ${{ matrix.arch }} -m release --target_minver=13.0 --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
- name: Configure Linux
if: runner.os == 'Linux'
run: xmake f -y -p linux -a ${{ matrix.arch }} -m release --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
- name: Configure Windows
if: runner.os == 'Windows'
run: xmake f -y -p windows -a ${{ matrix.arch }} -m release --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n
# xmake f installs required packages. Save before compiling or packaging the CLI,
# so failures in those later steps do not discard successfully installed dependencies.
- name: Save native dependencies
if: steps.native-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: ${{ env.XMAKE_PKG_INSTALLDIR }}
key: ${{ steps.native-cache.outputs.cache-primary-key }}
- name: Build
run: xmake build crossdesk-cli
- name: Sign macOS CLI
if: runner.os == 'macOS'
run: codesign --force --sign - build/macosx/${{ matrix.arch }}/release/crossdesk-cli
- name: Report native environment
run: xmake run crossdesk-cli doctor --json
- name: Package native program and npm entry
run: npm --prefix apps/cli run pack:npm
- uses: actions/upload-artifact@v6
with:
name: npm-${{ matrix.target }}
path: build/npm/*.tgz
if-no-files-found: error
+50 -13
View File
@@ -53,7 +53,6 @@ jobs:
EXPECTED_IOS_SDK: "26.5"
# Exercise the minimum supported version; local builds may use newer Xmake.
XMAKE_MIN_VERSION: "3.1.1"
XMAKE_REPO_REVISION: "121109352da272cd1a3d4ed52f2dcda34f839955"
steps:
- name: Extract version number
@@ -160,17 +159,21 @@ jobs:
uses: actions/cache@v5
with:
path: apps/ios/.xmake/packages
key: "${{ runner.os }}-xmake${{ env.XMAKE_MIN_VERSION }}-deps-ios-arm64-source-v1-xcode26.6-xrepo1211093-min16-${{ github.run_id }}"
key: "${{ runner.os }}-xmake${{ env.XMAKE_MIN_VERSION }}-deps-ios-arm64-source-v2-xcode26.6-min16-${{ hashFiles('apps/ios/scripts/build_minirtc_ios.sh', 'xmake.lua', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**') }}-${{ github.run_id }}"
restore-keys: |
${{ runner.os }}-xmake${{ env.XMAKE_MIN_VERSION }}-deps-ios-arm64-source-v1-xcode26.6-xrepo1211093-min16-
${{ runner.os }}-xmake${{ env.XMAKE_MIN_VERSION }}-deps-ios-arm64-source-v2-xcode26.6-min16-${{ hashFiles('apps/ios/scripts/build_minirtc_ios.sh', 'xmake.lua', 'deps/submodules/minirtc/xmake.lua', 'deps/submodules/minirtc/thirdparty/**') }}-
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Verify iOS license catalog
run: python3 -B apps/ios/scripts/generate_third_party_licenses.py
- name: Install Python build tools
shell: bash
run: |
# Xmake only accepts the host Python when all three modules exist;
# otherwise it falls back to compiling another Python from source.
python3 -m pip install --upgrade pip setuptools wheel
- name: Install xmake
uses: xmake-io/github-action-setup-xmake@v1
@@ -181,19 +184,40 @@ jobs:
shell: bash
run: |
xmake --version | tee "${RUNNER_TEMP}/xmake-version.txt"
python3 -B apps/ios/scripts/generate_third_party_licenses.py \
python3 -B apps/ios/scripts/verify_native_build.py \
--xmake-version "${RUNNER_TEMP}/xmake-version.txt"
- name: Pin xmake package repository
# Xcode buffers Run Script output until the phase finishes. Build the
# native archives here so dependency downloads and compiler commands are
# streamed to Actions; the Xcode phase then reuses the same build products.
- name: Build iOS native dependencies
timeout-minutes: 60
shell: bash
env:
CONFIGURATION: Release
PLATFORM_NAME: iphoneos
CURRENT_ARCH: arm64
CROSSDESK_NATIVE_VERBOSE: "1"
run: |
xmake repo -u
XMAKE_REPO_DIR="${HOME}/.xmake/repositories/xmake-repo"
git -C "${XMAKE_REPO_DIR}" fetch --depth=1 origin "${XMAKE_REPO_REVISION}"
git -C "${XMAKE_REPO_DIR}" checkout --detach "${XMAKE_REPO_REVISION}"
test "$(git -C "${XMAKE_REPO_DIR}" rev-parse HEAD)" = "${XMAKE_REPO_REVISION}"
set -o pipefail
(
trap 'kill "${SLEEP_PID:-}" 2>/dev/null || true; exit 0' TERM INT
while true; do
sleep 60 &
SLEEP_PID=$!
wait "${SLEEP_PID}" || exit 0
echo "iOS native build still running at $(date -u +%FT%TZ)"
ps -axo pid,ppid,state,etime,pcpu,comm | \
awk 'NR == 1 || /xmake|clang|cmake|ninja|meson|python|git|curl|make|zsh/'
done
) &
MONITOR_PID=$!
trap 'kill "${MONITOR_PID}" 2>/dev/null || true; wait "${MONITOR_PID}" 2>/dev/null || true' EXIT
apps/ios/scripts/build_minirtc_ios.sh 2>&1 | \
tee "${RUNNER_TEMP}/ios-native-build.log"
- name: Build CrossDesk Mobile
timeout-minutes: 30
shell: bash
run: |
DERIVED_DATA="${RUNNER_TEMP}/CrossDeskMobileDerivedData"
@@ -209,7 +233,20 @@ jobs:
COMPILER_INDEX_STORE_ENABLE=NO \
MARKETING_VERSION="${IOS_VERSION_BASE}" \
IPHONEOS_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET}" \
build
build 2>&1 | tee "${RUNNER_TEMP}/ios-app-build.log"
- name: Upload iOS build diagnostics
if: ${{ failure() || cancelled() }}
uses: actions/upload-artifact@v6
with:
name: ios-build-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ runner.temp }}/ios-native-build.log
${{ runner.temp }}/ios-app-build.log
~/.xmake/cache/packages/**/logs/*.txt
~/.xmake/cache/packages/**/meson-logs/meson-log.txt
include-hidden-files: true
if-no-files-found: ignore
- name: Verify CrossDesk Mobile
shell: bash
+159 -184
View File
@@ -9,8 +9,13 @@ on:
- "*"
- "!ios-v*"
- "!android-v*"
- "!cli-v*"
workflow_dispatch:
inputs:
desktop_version:
description: "Desktop version for branch builds, e.g. 1.7.2. Leave blank for 0.0.0; tags use their own version."
required: false
type: string
patch:
description: "Hotfix patch number, for example 1 or 2. Use 0 for a normal build."
required: false
@@ -20,6 +25,11 @@ on:
required: false
type: boolean
default: false
sign_macos:
description: "Sign and notarize macOS installers (automatic for v* release tags)."
required: false
type: boolean
default: false
permissions:
actions: read
@@ -28,11 +38,72 @@ permissions:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
XMAKE_MIN_VERSION: "3.1.1"
CROSSDESK_UPDATE_PUBLIC_KEY: ${{ vars.CROSSDESK_UPDATE_PUBLIC_KEY }}
CROSSDESK_DESKTOP_VERSION: ${{ inputs.desktop_version }}
jobs:
prepare-version:
name: Resolve desktop version
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.version.outputs.version }}
# Resolve the date once for every desktop build and the release job.
steps:
- name: Resolve desktop version
id: version
shell: bash
env:
PATCH_NUMBER: ${{ inputs.patch || '0' }}
run: |
VERSION_REF="${GITHUB_REF##*/}"
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
echo "::error::desktop_version must be a numeric version such as 1.7.2"
exit 1
fi
elif [[ -n "${CROSSDESK_DESKTOP_VERSION}" ]]; then
echo "::error::desktop_version is only supported for branch builds; tags determine release versions"
exit 1
fi
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "version=${VERSION_NUM}" >> "$GITHUB_OUTPUT"
echo "Desktop version: ${VERSION_NUM}"
build-linux:
name: Build Linux (${{ matrix.arch }})
needs: prepare-version
runs-on: ${{ matrix.runner }}
env:
LEGAL_VERSION: ${{ needs.prepare-version.outputs.version }}
strategy:
matrix:
include:
@@ -52,59 +123,6 @@ jobs:
options: --user root
steps:
- name: Extract version number
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
PATCH_NUMBER="${{ github.event.inputs.patch }}"
BUILD_DATE_OVERRIDE=""
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
echo "VERSION_BASE=${VERSION_BASE}" >> $GITHUB_ENV
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_ENV
echo "BUILD_DATE_OVERRIDE=${BUILD_DATE_OVERRIDE}" >> $GITHUB_ENV
- name: Set legal Debian version
shell: bash
run: |
BUILD_DATE="${BUILD_DATE_OVERRIDE}"
if [[ -z "${BUILD_DATE}" ]]; then
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
fi
if [[ ! "${VERSION_BASE}" =~ ^[0-9] ]]; then
VERSION_BASE="0.0.0-${VERSION_BASE}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
LEGAL_VERSION="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
LEGAL_VERSION="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "LEGAL_VERSION=${LEGAL_VERSION}" >> $GITHUB_ENV
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v5
with:
@@ -202,11 +220,15 @@ jobs:
# macOS
build-macos:
name: Build on macOS
needs: prepare-version
runs-on: ${{ matrix.runner }}
env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
MACOSX_DEPLOYMENT_TARGET: "14.0"
EXPECTED_MACOS_SDK: "26.5"
MACOS_SIGNING_REQUIRED: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.sign_macos) }}
strategy:
fail-fast: false
matrix:
include:
- arch: x64
@@ -223,45 +245,26 @@ jobs:
package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh
steps:
- name: Extract version number
id: version
- name: Validate macOS signing secrets
if: env.MACOS_SIGNING_REQUIRED == 'true'
shell: bash
env:
MACOS_APP_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_APP_CERTIFICATE_P12_BASE64 }}
MACOS_INSTALLER_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_P12_BASE64 }}
MACOS_P12_PASSWORD: ${{ secrets.MACOS_P12_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "VERSION_NUM=${VERSION_NUM}" >> $GITHUB_ENV
echo "VERSION_NUM=${VERSION_NUM}"
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_ENV
missing=0
for name in MACOS_APP_CERTIFICATE_P12_BASE64 MACOS_INSTALLER_CERTIFICATE_P12_BASE64 \
MACOS_P12_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do
if [[ -z "${!name:-}" ]]; then
echo "::error::Missing required Actions secret: ${name}"
missing=1
fi
done
exit "$missing"
- name: Select Xcode 26.6
shell: bash
@@ -301,7 +304,21 @@ jobs:
xmake f --target_minver=${MACOSX_DEPLOYMENT_TARGET} --CROSSDESK_VERSION=${VERSION_NUM} --USE_CUDA=false -vyD
xmake b -vy crossdesk
- name: Import Developer ID certificates and notarization credentials
id: macos_signing
if: env.MACOS_SIGNING_REQUIRED == 'true'
shell: bash
env:
MACOS_APP_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_APP_CERTIFICATE_P12_BASE64 }}
MACOS_INSTALLER_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_P12_BASE64 }}
MACOS_P12_PASSWORD: ${{ secrets.MACOS_P12_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: bash .github/scripts/setup_macos_signing.sh
- name: Package CrossDesk app
id: macos_package
run: |
chmod +x ${{ matrix.package_script }}
${{ matrix.package_script }} ${VERSION_NUM}
@@ -322,17 +339,56 @@ jobs:
./apps/desktop/scripts/macos/verify_app.sh "${APP_PATH}" "${{ matrix.binary_arch }}" "${EXPECTED_MACOS_SDK}"
- name: Notarize and staple macOS installer
id: macos_notarization
if: env.MACOS_SIGNING_REQUIRED == 'true'
timeout-minutes: 65
shell: bash
env:
CROSSDESK_MACOS_NOTARY_PROFILE: ${{ steps.macos_signing.outputs.notary_profile }}
CROSSDESK_MACOS_NOTARY_TIMEOUT: 60m
CROSSDESK_MACOS_NOTARY_S3_ACCELERATION: "false"
run: |
bash ./apps/desktop/scripts/macos/notarize_pkg.sh \
"crossdesk-macos-${{ matrix.arch }}-${VERSION_NUM}.pkg"
- name: Upload macOS notarization diagnostics
if: always() && env.MACOS_SIGNING_REQUIRED == 'true'
uses: actions/upload-artifact@v6
with:
name: macos-notarization-${{ matrix.arch }}-${{ env.VERSION_NUM }}
path: crossdesk-macos-${{ matrix.arch }}-${{ env.VERSION_NUM }}.pkg.notary*.json
if-no-files-found: ignore
retention-days: 14
- name: Preserve signed installer after notarization failure
if: failure() && env.MACOS_SIGNING_REQUIRED == 'true' && steps.macos_package.outcome == 'success'
uses: actions/upload-artifact@v6
with:
name: macos-pending-notarization-${{ matrix.arch }}-${{ env.VERSION_NUM }}
path: crossdesk-macos-${{ matrix.arch }}-${{ env.VERSION_NUM }}.pkg
if-no-files-found: error
retention-days: 14
- name: Upload build artifacts
uses: actions/upload-artifact@v6
with:
name: crossdesk-macos-${{ matrix.arch }}-${{ env.VERSION_NUM }}
path: crossdesk-macos-${{ matrix.arch }}-${{ env.VERSION_NUM }}.pkg
if-no-files-found: error
- name: Move files to release dir
run: |
mkdir -p release
cp crossdesk-macos-${{ matrix.arch }}-${{ env.VERSION_NUM }}.pkg release/
- name: Remove macOS signing credentials
if: always() && env.MACOS_SIGNING_REQUIRED == 'true'
shell: bash
run: |
security delete-keychain "${RUNNER_TEMP}/crossdesk-macos-signing/signing.keychain-db" 2>/dev/null || true
rm -rf "${RUNNER_TEMP:?}/crossdesk-macos-signing"
# iOS (also available as a standalone workflow)
build-ios:
name: Build iOS
@@ -354,50 +410,12 @@ jobs:
# Windows
build-windows-x64:
name: Build on Windows x64
needs: prepare-version
runs-on: windows-2022
env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
XMAKE_GLOBALDIR: D:\xmake_global
steps:
- name: Extract version number
shell: pwsh
run: |
$ref = $env:GITHUB_REF
$version = $ref -replace '^refs/(tags|heads)/', ''
$version = $version -replace '^v', ''
$version = $version -replace '/', '-'
$BUILD_DATE = ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId((Get-Date), "China Standard Time")).ToString("yyyyMMdd")
$PATCH_NUMBER = "${{ github.event.inputs.patch }}"
if ($PATCH_NUMBER -notmatch '^[0-9]+$') {
$PATCH_NUMBER = "0"
}
if ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$') {
$version = $Matches[1]
$PATCH_NUMBER = $Matches[3]
$BUILD_DATE = $Matches[4]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$') {
$version = $Matches[1]
$BUILD_DATE = $Matches[3]
$PATCH_NUMBER = $Matches[4]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$') {
$version = $Matches[1]
$BUILD_DATE = $Matches[3]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$' -and $PATCH_NUMBER -eq "0") {
$version = $Matches[1]
$PATCH_NUMBER = $Matches[3]
}
if ($PATCH_NUMBER -ne "0") {
$VERSION_NUM = "v$version-$PATCH_NUMBER-$BUILD_DATE"
} else {
$VERSION_NUM = "v$version-$BUILD_DATE"
}
echo "VERSION_NUM=$VERSION_NUM" >> $env:GITHUB_ENV
echo "BUILD_DATE=$BUILD_DATE" >> $env:GITHUB_ENV
echo "PATCH_NUMBER=$PATCH_NUMBER" >> $env:GITHUB_ENV
- name: Cache xmake dependencies
uses: actions/cache@v5
with:
@@ -598,7 +616,7 @@ jobs:
release:
name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-linux, build-macos, build-ios, build-android, sign-android, build-windows-x64]
needs: [prepare-version, build-linux, build-macos, build-ios, build-android, sign-android, build-windows-x64]
runs-on: ubuntu-latest
concurrency:
group: publish-release-metadata
@@ -613,50 +631,6 @@ jobs:
with:
path: artifacts
- name: Extract version number
id: version
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
BUILD_DATE_ISO="${BUILD_DATE:0:4}-${BUILD_DATE:4:2}-${BUILD_DATE:6:2}"
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="${VERSION_BASE}-${BUILD_DATE}"
fi
VERSION_WITH_V="v${VERSION_NUM}"
echo "VERSION_NUM=${VERSION_NUM}" >> $GITHUB_OUTPUT
echo "VERSION_WITH_V=${VERSION_WITH_V}" >> $GITHUB_OUTPUT
echo "VERSION_BASE=${VERSION_BASE}" >> $GITHUB_OUTPUT
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_OUTPUT
echo "BUILD_DATE_ISO=${BUILD_DATE_ISO}" >> $GITHUB_OUTPUT
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_OUTPUT
- name: Prepare release notes
run: |
cat > "$RUNNER_TEMP/release-notes.md" << EOF
@@ -686,18 +660,18 @@ jobs:
- name: Rename artifacts
run: |
mkdir -p release
cp artifacts/crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg
cp artifacts/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg
cp artifacts/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}.pkg
cp artifacts/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}.pkg
for ARCH in amd64 arm64; do
for PACKAGE in crossdesk crossdesk-virtual-desktop-xfce; do
FILE="${PACKAGE}-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}.deb"
cp "artifacts/crossdesk-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}/${FILE}" "release/${FILE}"
FILE="${PACKAGE}-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}.deb"
cp "artifacts/crossdesk-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}/${FILE}" "release/${FILE}"
done
done
cp artifacts/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}/* release/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}.zip
cp artifacts/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}/*.apk release/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}.apk
cp artifacts/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip
cp artifacts/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}.zip
- name: List release files
run: ls -lh release/
@@ -705,10 +679,10 @@ jobs:
- name: Upload to Versioned GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.VERSION_WITH_V }}
tag_name: ${{ needs.prepare-version.outputs.version }}
# The pushed tag already determines the release source commit.
target_commitish: ${{ github.event.repository.default_branch }}
name: Release ${{ steps.version.outputs.VERSION_WITH_V }}
name: Release ${{ needs.prepare-version.outputs.version }}
draft: false
prerelease: false
files: release/*
@@ -751,7 +725,8 @@ jobs:
- name: Generate version.json for all platforms
env:
RELEASE_TAG: ${{ steps.version.outputs.VERSION_WITH_V }}
RELEASE_TAG: ${{ needs.prepare-version.outputs.version }}
CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }}
run: |
python3 .github/scripts/update_version_manifest.py \
--current current-version.json --output version.json --artifacts release \
+72
View File
@@ -0,0 +1,72 @@
name: Publish CLI to npm
on:
push:
tags: ["cli-v*"]
workflow_dispatch:
inputs:
dry_run:
description: "Build and validate all packages without publishing (disable only on a cli-v* tag)"
type: boolean
default: true
required: true
permissions:
contents: read
jobs:
prepare:
runs-on: ubuntu-24.04
outputs:
dry_run: ${{ steps.release.outputs.dry_run }}
version: ${{ steps.release.outputs.version }}
tag: ${{ steps.release.outputs.tag }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 24
package-manager-cache: false
- name: Validate release version and Git tag
id: release
env:
RELEASE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
run: node apps/cli/scripts/release-npm.cjs plan
build:
needs: prepare
uses: ./.github/workflows/build-cli.yml
publish:
needs: [prepare, build]
runs-on: ubuntu-24.04
timeout-minutes: 45
environment: npm
permissions:
contents: read
id-token: write
# Serialize registry writes across versions; never cancel an in-flight publish.
concurrency:
group: npm-cli-publish
cancel-in-progress: false
env:
RELEASE_DRY_RUN: ${{ needs.prepare.outputs.dry_run }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 24
package-manager-cache: false
- name: Install npm with Trusted Publishing support
run: npm install --global npm@12.2.1 --ignore-scripts --no-audit --no-fund
# Keep each platform in its own directory: all five artifacts contain an
# entry tarball, and Windows checkouts may have different line endings.
- uses: actions/download-artifact@v7
with:
pattern: npm-*
path: ${{ runner.temp }}/cli-release
merge-multiple: false
- name: Validate and publish six packages
env:
RELEASE_ARTIFACTS: ${{ runner.temp }}/cli-release
run: node apps/cli/scripts/release-npm.cjs publish "$RELEASE_ARTIFACTS"
+5 -5
View File
@@ -4,7 +4,7 @@ on:
workflow_call:
inputs:
version:
description: "Android artifact version, for example v1.6.0-20261002."
description: "Android artifact version, for example v1.7.3-20261026 or v1.7.3-1-20261026."
required: true
type: string
secrets:
@@ -19,7 +19,7 @@ on:
workflow_dispatch:
inputs:
source_tag:
description: "Existing Android release to sign without rebuilding, for example android-v1.6.0-20261002."
description: "Existing Android release to sign without rebuilding, for example android-v1.7.3-1-20261026."
required: true
type: string
@@ -61,11 +61,11 @@ jobs:
if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" and not version:
tag = os.environ["SOURCE_TAG"]
if not tag.startswith("android-v"):
raise ValueError("Use an existing android-v<app-version>-YYYYMMDD release")
raise ValueError("Use an existing android-v<app-version>[-<patch>]-YYYYMMDD release")
version = tag.removeprefix("android-")
match = re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+-([0-9]{8})", version)
match = re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9]+)?-([0-9]{8})", version)
if not match:
raise ValueError("Invalid Android artifact version")
raise ValueError("Invalid Android artifact version; expected v<app-version>[-<patch>]-YYYYMMDD")
datetime.datetime.strptime(match[1], "%Y%m%d")
with open(os.environ["GITHUB_OUTPUT"], "a") as stream:
stream.write(f"version={version}\n")
@@ -70,6 +70,9 @@ jobs:
- name: Merge release version and download URLs
shell: bash
env:
CROSSDESK_UPDATE_PUBLIC_KEY: ${{ vars.CROSSDESK_UPDATE_PUBLIC_KEY }}
CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }}
run: |
python3 .github/scripts/update_version_manifest.py \
--current current-version.json --output version.json --artifacts release \
+3
View File
@@ -13,3 +13,6 @@ xcuserdata/
# VSCode cache
.vscode
# CLI npm dependencies
node_modules/
+3
View File
@@ -1,3 +1,6 @@
CrossDesk
SPDX-License-Identifier: GPL-3.0-only
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
+17 -9
View File
@@ -1,18 +1,18 @@
# CrossDesk Privacy Policy
Last updated: October 10, 2026
Last updated: October 15, 2026
[中文](#中文) | [English](#english)
## 中文
更新日期:2026 年 10 月 10 日
更新日期:2026 年 10 月 15 日
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
### 一、适用范围
本政策适用于 CrossDesk 桌面客户端、iOS 与 Android 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
本政策适用于 CrossDesk 桌面客户端、iOS、Android 与鸿蒙移动客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
@@ -50,11 +50,13 @@ Android 中的设备身份及您选择保存的访问密码使用 Android Keysto
iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。
Android 默认信令连接同样进行 TLS 证书校验。
Android 与鸿蒙移动端默认信令连接同样进行 TLS 证书校验。
鸿蒙移动端使用系统 Asset Store 加密保存设备身份及您选择保存的访问密码,明确禁止凭据跨设备同步。设置和连接记录按服务器隔离。远程预览默认关闭,单独确认后从下一次连接起保存一张 640×360 预览;预览保存在应用私有缓存中,不上传,可随时清除,也可能被系统回收。关闭预览或撤回联网授权会清除预览。接收文件保存在应用私有目录,您可在会话中通过系统文件选择器导出。
### 五、移动端权限与授权选择
**隐私授权。** iOS 与 Android 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
**隐私授权。** iOS、Android 与鸿蒙移动端 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。未授权时,重新打开或从后台返回应用会再次提示。
**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。
@@ -64,6 +66,8 @@ Android 客户端同样作为控制端接收远程画面与音频,不采集本
Android 远程会话优先使用系统画中画在切换应用后继续显示画面。画中画不可用时,通过前台服务和连接通知保持您已建立的远程连接;后台暂停本地画面渲染和音频播放,不申请悬浮窗权限。通知权限用于提供返回及断开入口,CPU 唤醒锁用于会话后台期间维持连接,返回前台或会话结束时释放。您可以在应用或连接通知中断开,移除应用任务也会结束会话及保活服务。
鸿蒙手机和平板只作为控制端,不申请本机录屏、输入注入、摄像头或麦克风权限;文件选择和剪贴板操作由您主动触发。已建立会话在切入后台时优先使用系统画中画,提供静音控制;画中画不可用时申请数据传输后台任务并显示系统通知。若系统不允许或停止后台任务且画中画不可用,会话会断开。后台切换释放远端按键;空闲后台不维持信令连接。完成联网授权后,应用在前台检查鸿蒙本机版本,并在连接后检查远端版本;版本请求不携带设备身份或远端版本。
应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。
### 六、查询与删除请求
@@ -86,13 +90,13 @@ Android 远程会话优先使用系统画中画在切换应用后继续显示画
## English
Updated: October 2, 2026
Updated: October 15, 2026
CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software.
### 1. Scope
This policy applies to the CrossDesk desktop, iOS and Android clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
This policy applies to the CrossDesk desktop, iOS, Android and HarmonyOS mobile clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply.
@@ -130,11 +134,13 @@ On Android, device identities and access passwords you choose to save are encryp
On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people.
Default signaling connections on Android also validate TLS certificates.
Default signaling connections on Android and HarmonyOS mobile also validate TLS certificates.
HarmonyOS mobile uses the system Asset Store to encrypt device identities and saved access passwords, with cross-device credential synchronization disabled. Settings and connection records are scoped to the server. Remote previews are off by default. After separate confirmation, one 640×360 preview is saved per connection starting with the next connection. Previews remain in private app cache, are never uploaded, can be cleared and may be reclaimed by the system. Disabling previews or withdrawing network consent clears them. Received files remain in private app storage and can be exported through the system document picker during the session.
### 5. Mobile permissions and consent choices
**Privacy authorization.** On first use of iOS or Android, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
**Privacy authorization.** On first use of iOS, Android or HarmonyOS mobile, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable. Without consent, reopening the app or returning from the background presents the notice again.
**Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data.
@@ -144,6 +150,8 @@ The Android client also receives remote screens and audio as a controller and do
Android remote sessions prefer system picture-in-picture to continue displaying video when you switch apps. If picture-in-picture is unavailable, a foreground service and connection notification maintain the remote connection you have established. Local video rendering and audio playback pause in the background; no overlay permission is requested. Notification permission provides return and disconnect actions. A CPU wake lock helps maintain the connection while the session is backgrounded and is released on foreground return or session end. You can disconnect in the app or its connection notification; removing the app task also ends the session and its foreground service.
HarmonyOS phones and tablets act only as controllers and do not request local screen recording, input injection, camera or microphone permissions. File selection and clipboard actions are user initiated. An established session prefers system picture-in-picture with a mute control when backgrounded. If unavailable, the app requests a data-transfer background task with a system notification. The session ends if the system disallows or stops that task while picture-in-picture is unavailable. Backgrounding releases remote keys; idle background sessions do not retain signaling. After network consent, the foreground app checks the local HarmonyOS version and checks the remote version after connection. These requests contain neither device identity nor the remote version.
The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking.
### 6. Access and deletion requests
+182 -210
View File
@@ -1,25 +1,85 @@
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
<h1>CrossDesk</h1>
<p>轻量、跨平台的远程桌面,让电脑、浏览器和 iPhone / iPad 连接到同一张桌面。</p>
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk 标志:图标在左,双色文字在右" />
<p>轻量、跨平台的远程桌面,支持电脑、浏览器、移动端和终端连接,用桌面墙集中查看与操控多台设备。</p>
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
</div>
<div align="center">
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android-blue)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
[![爱发电](https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-946CE6?logo=afdian&logoColor=white)](https://afdian.com/a/kunkundi)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF6433?logo=kofi&logoColor=white)](https://ko-fi.com/kunkundi)
[![GitHub Sponsors](https://img.shields.io/badge/GitHub_Sponsors-C93C45?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/kunkundi)
</div>
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS / Android 客户端控制电脑。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。
CrossDesk 让你从电脑、浏览器、手机或终端连接远端电脑,也能通过 **桌面墙(DeskHub)** 集中查看与操控多台设备。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),支持自托管。
> 本文按当前仓库代码整理。截图展示当前 Slint 桌面界面,使用演示 ID、密码、在线状态和连接记录;缩略图来自公开页面;下载包的功能与系统要求请同时核对对应 Release 说明。原生 iOS 客户端的构建、签名方式见下文。
[主要功能](#features) · [下载与平台支持](#download) · [快速连接](#quick-start) · [终端 CLI](#cli) · [IP 直连](#lan-direct) · [界面预览](#preview) · [使用指南](docs/USER_GUIDE.md) · [自托管](#self-hosting) · [构建与反馈](#development) · [赞助](#sponsor)
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [Android](#android) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md)
<a id="features"></a>
## 主要功能
- **跨平台远控:** Windows、macOS、Linux 桌面互控,浏览器及原生 iOS / Android 客户端访问电脑;鸿蒙 PC 与移动端处于开发阶段。
- **局域网 IP 直连:** 桌面客户端通过 IP、端口和六位密码连接,无需云端信令服务;支持独立端口、记住密码及按模式显示近期设备。
- **终端主控:** 在终端中通过设备 ID 观看和操控远程桌面,支持 ANSI / Kitty 画面、局部缩放与平移、持续本地命令模式及 Pi 扩展,可单独构建。
- **桌面墙:** 多设备实时预览、分组、放大操控、群控和批量文件分发。
- **画面与声音:** H.264 / AV1、硬件编解码、远端音频,以及会话内画质和帧率调整;可用能力随平台与设备而异。
- **日常协作:** 多显示器切换、键鼠与组合键、文本剪贴板、文件传输。
- **部署与运维:** P2P 直连、TURN 中继、SRTP 媒体加密、自托管,以及 Windows 登录前远控和 Linux 无头运行。
<a id="download"></a>
## 下载与平台支持
从 [官方网站](https://www.crossdesk.cn/) 或 [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) 下载与你的系统和 CPU 架构匹配的安装包;也可直接打开 [Web 客户端](https://web.crossdesk.cn/)。
| 平台 | 支持范围与安装要求 |
| --- | --- |
| Windows | **控制 / 被控**;Windows 10+,x64;`.exe` 安装包,也支持便携构建 |
| macOS | **控制 / 被控**;macOS 14.0+;按 Intel / Apple Silicon 选择 x64 / arm64 `.pkg` |
| Linux | **控制 / 被控**;Ubuntu 20.04+、glibc 2.31 基线;amd64 / arm64 `.deb` |
| iOS / iPadOS | **仅控制**;iOS 16.0+;发布包为未签名应用,需自行签名,或使用 Xcode 构建安装 |
| Android | **仅控制**;Android 8.0+,arm64;下载已签名 APK 安装,或自行构建 |
| 鸿蒙 PC(开发版) | **控制 / 被控**;API 26,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| 鸿蒙手机 / 平板(开发版) | **仅控制**;API 20+,arm64;使用 DevEco 构建并签名 HAP,仍在进行真机验收 |
| Web | **仅控制**;支持 WebRTC 的浏览器;无需在控制端安装客户端 |
| 终端 CLI(开发版) | **仅控制**;构建目标为 macOS / Linux(x64、arm64)和 Windows(x64);从源码构建,平台验收范围见 [CLI 说明](apps/cli/README.md) |
系统要求按当前源码与构建配置整理,具体安装包以对应 Release 说明为准。各客户端的功能范围见 [终端 CLI](#cli) 与 [移动端与鸿蒙](#mobile)。
- **Linux:** 已有桌面选普通版 `crossdesk-linux-…deb`;需要独立 Xfce 桌面的服务器选 `crossdesk-virtual-desktop-xfce-…deb`。两者选择其一,安装与启动方法见 [使用指南](docs/USER_GUIDE.md#installation) 和 [无头运行](docs/HEADLESS.md)。
- **macOS:** 首次运行时按提示授予屏幕录制及辅助功能权限;前者用于桌面采集,后者用于远程键鼠输入。[权限说明](docs/USER_GUIDE.md#installation)
- **iOS / 鸿蒙:** 签名与构建步骤分别见 [iOS](apps/ios/README.md) 和 [鸿蒙](apps/harmony/README.md),不要将未签名产物当作可直接安装的应用。
<a id="quick-start"></a>
## 快速连接
### 通过设备 ID 连接
1. **准备被控电脑。** 安装并运行 CrossDesk,切换至 ID 连接模式,等待显示“已连接服务器”,查看“本桌面”中的本机 ID 和密码。
2. **发起连接。** 在另一台电脑的 CrossDesk 中输入被控电脑的 9 位 ID,点击“连接”;也可使用原生移动端或 [Web 客户端](https://web.crossdesk.cn/)。
3. **验证密码。** 输入被控电脑当前的连接密码,等待远程画面出现。桌面端可勾选“记住密码”,后续从“近期连接”重连。
使用自建服务器时,两端需要接入同一服务。更多连接、密码及浏览器操作见 [使用指南](docs/USER_GUIDE.md#quick-start);连接失败、黑屏或无法操作键鼠时先查阅 [常见问题](docs/FAQ.md)。
<a id="lan-direct"></a>
### 通过局域网 IP 直连
1. **切换模式。** 两台电脑均运行支持 IP 直连的桌面客户端,点击左上角模式按钮切换到 **IP 直连模式**。若按钮未显示,在 **设置 → 常规 → 网络连接** 中勾选 **启用 IP 直连** 并保存。
2. **填写地址。** 在控制端输入被控电脑显示的 `IP:端口`,例如 `192.168.1.20:52888` 或 `[2001:db8::20]:52888`,点击“连接”。只输入 IP 时使用默认端口 **52888**;请以对方界面显示的实际端口为准。
3. **输入密码。** 输入被控电脑在 IP 直连模式下显示的 **6 位英文字母或数字密码**,可勾选“记住密码”。切换模式时,“近期连接”同步切换为对应模式的设备记录。
两台电脑需要在局域网内可互相访问;网络可以有外网,也可以没有外网,直连会话不依赖云端信令服务。主窗口可直接切换本机 IP、修改端口和密码,默认开启所有 IP 接口监听,支持 IPv4 和 IPv6,支持通过 VPN 等接口连接,可在设置的“网络连接”中调整;关闭设置中的“启用 IP 直连”会停止监听、断开 IP 直连会话并隐藏模式按钮。详见 [IP 直连使用指南](docs/USER_GUIDE.md#lan-direct)。
<a id="preview"></a>
@@ -27,255 +87,167 @@ CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览
### 桌面客户端
主窗口集中展示本机 ID、连接密码、远程连接入口和近期连接记录。
本机凭据、远程连接和近期设备集中在主窗口,左上角提供连接模式切换和桌面墙入口。
<p align="center">
<img src="docs/images/desktop-main-zh.png" width="800" alt="当前桌面客户端示例:已连接服务器,三台在线设备均显示页面缩略图" />
<img src="docs/images/desktop-main-zh.png" width="800" alt="CrossDesk ID 连接模式:模式切换、本机 ID、远程连接入口和近期设备;数据为演示" />
</p>
### Web 客户端
### IP 直连模式
在浏览器中输入远程设备 ID 和密码,无需在控制端安装桌面客户端。
切换本机 IP、修改端口与密码,通过对方地址连接;下方展示独立的局域网近期设备。
<p align="center">
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端当前连接首页" />
<img src="docs/images/desktop-lan-zh.png" width="800" alt="CrossDesk IP 直连模式:本机 IP 和端口编辑入口、六位密码、远端地址输入和局域网近期设备;数据为演示" />
</p>
图片来源与更新方式见 [截图说明](docs/images/README.md)。
<a id="deskhub"></a>
## 主要功能
### 桌面墙(DeskHub)
| 能力 | 当前支持 |
| --- | --- |
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS / Android 作为控制端 |
| 实时画面与声音 | H.264 / AV1、硬件编解码、远端声音播放;会话中可调整画质、30 / 60 fps 与画面偏好 |
| 多设备与多显示器 | 近期连接、设备别名、会话标签与远端显示器切换 |
| 输入与协作 | 键鼠输入、远端光标同步、组合键、文本剪贴板同步、文件传输 |
| 网络与部署 | P2P 直连、TURN 中继与强制中继、SRTP 媒体加密、自托管信令与中继服务 |
| 隐私屏 | 被连接时自动开启,或在会话工具栏切换;可用性取决于被控端系统与采集方式 |
| Windows 锁屏控制 | 通过 CrossDesk Service 支持锁屏、登录界面及安全桌面的输入转发 |
| Linux 无头运行 | 复用已有桌面和应用,或创建独立 Xvfb 虚拟桌面;终端控制台可查看状态、修改密码和设置,见 [运行说明](docs/HEADLESS.md) |
按分组查看多台设备,点击画面放大,再选择接管操控;支持批量连接、静音与文件分发。
各客户端的入口和能力有所不同;桌面会话操作见下文,iOS 的具体功能见 [开发说明](apps/ios/README.md)。
<p align="center">
<img src="docs/images/desktop-deskhub-zh.png" width="960" alt="DeskHub 桌面墙:六台演示设备的远程画面与管理工具栏" />
</p>
<a id="download"></a>
## 下载与安装
前往 [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) 或 [官方网站](https://www.crossdesk.cn/),按系统与 CPU 架构选择安装包。
| 平台 | 当前源码 / CI 基线 | 安装方式 |
| --- | --- | --- |
| Windows | Windows 10+,x64 | `.exe` 安装包;便携构建也受支持 |
| macOS | macOS 14.0+,Intel / Apple Silicon | 选择 x64 或 arm64 的 `.pkg` |
| Linux | Ubuntu 20.04+,amd64 / arm64,glibc 2.31 基线 | 普通版 / Xfce 虚拟桌面版 `.deb` |
| iOS / iPadOS | iOS 16.0+,arm64 真机 | 原生客户端;使用 Xcode 构建并签名,CI 产物为未签名应用 |
| Android | Android 8.0+,arm64 | 原生控制端;使用 Gradle 构建 APK,见 [开发说明](apps/android/README.md) |
| Web | 支持 WebRTC 的浏览器 | 访问 [web.crossdesk.cn](https://web.crossdesk.cn/) |
Linux 每个架构提供两个可独立安装的包,**选择其中一个**:
| 安装包 | 适用场景 |
| --- | --- |
| `crossdesk-linux-<arch>-<version>.deb`(推荐) | 已有桌面的电脑;不会额外安装 Xfce 或 Xvfb |
| `crossdesk-virtual-desktop-xfce-linux-<arch>-<version>.deb` | 需要创建独立桌面的服务器;APT 会额外安装 **Xfce、Xvfb 和 D-Bus 会话工具** |
普通版安装示例(将文件名替换为实际下载名称,arm64 选择对应架构):
```bash
sudo apt install "./crossdesk-linux-amd64-<version>.deb"
```
如需虚拟桌面版,改为安装:
```bash
sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
```
两个包包含相同的完整程序,互相替换并保留用户配置;普通版自行补齐依赖后也能创建虚拟桌面。安装虚拟桌面版不会自动启动会话或切换默认桌面,启动方法见 [Linux 无头运行](docs/HEADLESS.md)。应用更新的下载按钮会选择已安装的包类型。已有版本升级后不会自动删除先前安装的 Xfce。
**macOS 首次运行:** 按应用提示,在“系统设置 → 隐私与安全性”中授予 CrossDesk **屏幕录制**(新系统可能显示为“屏幕与系统音频录制”)及 **辅助功能**权限,再按系统提示重新打开应用。前者用于捕获桌面,后者用于远程键鼠输入。
<a id="quick-start"></a>
## 快速连接
### 从另一台电脑连接
1. **准备被控电脑。** 安装并运行 CrossDesk,等待底部显示“已连接服务器”。在左侧“本桌面”查看 **本机 ID** 和 **密码**,提供给控制端。
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 ID,点击 **→**。
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连,也可编辑别名或删除记录。
本机密码旁的眼睛按钮用于显示 / 隐藏密码,刷新按钮用于生成新的随机密码,铅笔按钮用于手动修改。密码统一为 **6 位数字或英文字母**。修改或刷新时需保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
### 从浏览器连接
1. 保持被控电脑上的 CrossDesk 运行,并确认已连接服务器。
2. 打开 [Web 客户端](https://web.crossdesk.cn/),输入 **远程设备 ID** 和 **密码**,点击“连接”。
3. 连接后使用页面提供的显示器、鼠标模式和键盘控件操作远端;手机和平板也可通过浏览器访问。
如果使用自建服务器,需要让控制端与被控端接入同一服务;Web 部署配置见 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
群控会向所有已连接设备发送输入;关闭桌面墙会断开全部远程连接。操作范围与详细用法见 [桌面墙使用指南](docs/USER_GUIDE.md#deskhub)。截图使用演示数据,更多界面及来源见 [使用指南](docs/USER_GUIDE.md) 与 [截图说明](docs/images/README.md)。
<a id="session"></a>
## 会话中的常用操作
远程窗口通过会话标签切换设备,控制栏提供以下操作;控制栏收起时先点击展开按钮。将鼠标悬停在图标上可查看提示。
| 入口 | 用途 |
| --- | --- |
| 显示器 | 切换远端显示器 |
| 键盘 | 发送组合键;Windows 的 `Ctrl+Alt+Del` 依赖远端服务 |
| 鼠标 | 控制 / 释放远端鼠标 |
| 扬声器 | 播放声音 / 静音 |
| 文件夹 | 选择文件发送到远端,并查看传输进度 |
| 画面设置 | 在当前会话中调整画面质量、采集帧率与画面偏好 |
| 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继及加密状态 |
| 隐私屏 | 开启或关闭被控电脑的隐私屏;悬停查看当前状态与可用操作 |
| 全屏 | 进入 / 退出全屏 |
| 断开连接 | 结束当前远程会话 |
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
### 在会话中调整画面
连接成功后,点击工具栏中的 **画面设置**(滑杆图标),直接选择低 / 中 / 高画质、**30 / 60 fps**,以及帧率优先 / 画质优先 / 平衡。选项会发送给当前被控端,无需断开重连;实际画面效果取决于网络、设备与编码器能力。旧版被控端不支持时,菜单会提示并禁用选项。
<p align="center">
<img src="docs/images/desktop-stream-video-settings-zh.png" width="800" alt="当前远程会话的画面设置菜单:画面质量、采集帧率与画面偏好;背景为公开页面演示图" />
</p>
<a id="settings"></a>
## 客户端设置与更新
## 会话操作与设置
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。画质、帧率和画面偏好在远程会话工具栏中调整。
远程会话支持显示器切换、画质与帧率调整、声音、文本剪贴板同步和文件传输。工具栏还可查看网络状态、控制隐私屏;桌面设置中可配置 IP 直连开关、编码、中继、自托管与桌面墙画面预算。
<p align="center">
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前客户端设置:编码格式、硬件编解码、中继、强制中继与自动隐私屏" />
</p>
- [会话操作](docs/USER_GUIDE.md#session):工具栏、文本剪贴板、文件保存和隐私屏。
- [客户端设置与更新](docs/USER_GUIDE.md#settings):常规设置、桌面墙传输及版本检查。
- [桌面墙技术说明](docs/deskhub.md):预览订阅、带宽分配、协议与兼容性。
| 设置 | 说明 |
<a id="cli"></a>
## 终端客户端 CLI
`crossdesk-cli` 在终端中通过 9 位设备 ID 连接被控电脑,默认只读、静音。普通终端使用 ANSI 字符画面,支持 Kitty 图形协议的终端可显示像素画面;可切换显示器、调整画质、放大细节和平移,也可明确开启键鼠控制。CLI 可以独立于桌面 GUI 构建,被控电脑仍需运行 CrossDesk 并提供可捕获的图形会话,信令服务需支持 `cli` 临时身份注册。
### 从源码构建并连接
准备 Git、Xmake 3.1.1 或以上、C++17 编译工具链及对应平台依赖。以下以 macOS / Linux shell 为例;Windows 的 Git Bash / 开发者 PowerShell 步骤见 [CLI 构建说明](apps/cli/docs/terminal-client.md#构建与安装)。
```sh
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
./apps/cli/scripts/prepare-deps.sh
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
xmake build crossdesk-cli
xmake run crossdesk-cli doctor --json
xmake run crossdesk-cli 123456789
```
已有仓库时从仓库根目录执行依赖准备及后续命令。将 `123456789` 换成被控电脑的 ID,密码在连接时隐藏输入。`doctor` 只检查本机环境,不发起连接。仓库默认构建桌面端;以上配置只构建 CLI,需要同时构建桌面端时将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。产物位于 `build/<platform>/<arch>/release/`。
ANSI 全景受终端字符网格限制。阅读小字可添加 `--quality high --zoom native`,或进入画面后按 `d` 查看细节;`--renderer kitty` 需要终端支持对应协议。显示、鼠标报告、SSH 和终端兼容性见 [使用说明](apps/cli/docs/terminal-client.md)。
### 控制、查看与退出
| 模式 / 按键 | 行为 |
| --- | --- |
| 语言 | 中文、English、Русский |
| 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 |
| 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 |
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
| 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 |
| 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 |
| 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 |
| 自托管配置 | 填写服务器地址和信令服务端口,并勾选启用 |
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
| 文件保存路径 | 选择接收文件的目录 |
| 只读模式 | `c` 开启远端控制;`q` 或 `Ctrl+C` 退出 CLI |
| 控制模式 | 键鼠输入传给远端;`Ctrl+]` 进入本地命令模式并暂停远端输入 |
| 本地命令模式 | 执行命令后持续保持;`c` 选择手动返回后是否启用控制,`q` 退出 CLI |
| `Esc` / `Ctrl+C` | 在本地命令模式中手动返回所选的控制或只读模式 |
| `+` / `=` / `-` | 在只读或本地命令模式中放大 / 缩小 |
| `d` / `f` | 在只读或本地命令模式中切换原始细节 / 完整桌面;只读时也可点击目标进入细节 |
| 方向键 / `i j k l` | 在只读或本地命令模式中平移;`i` 上、`j` 左、`k` 下、`l` 右 |
### 检查更新
`Ctrl+]` 模式不会因为缩放、平移或按 `c` 自动结束。状态栏中的 `CONTROL PAUSED` / `READ ONLY` 表示返回后是否启用控制。控制模式中的普通 `c`、`q` 和 `Ctrl+C` 会发送到远端,需要先按 `Ctrl+]` 执行本地操作;再次按 `Ctrl+]` 也可返回,若返回控制模式则向远端发送一次该组合键。
客户端启动后会在后台检查版本,发现新版本时自动弹出更新提示,可查看更新说明并前往下载页面。关闭提示后,本次运行中的后台检查不会再次弹窗,主窗口菜单入口仍会显示更新提示;重新启动客户端后,如仍有新版本则再次提示。打开 **☰ → 关于** 可查看版本;有新版本时,点击版本信息下方的 **更新日志** 可再次打开更新提示,也可点击 **检查更新** 手动检查。
### Pi 扩展与功能范围
<p align="center">
<img src="docs/images/desktop-about-zh.png" width="720" alt="关于窗口与检查更新按钮,版本号和检查结果为演示数据" />
</p>
CLI 附带 Pi 自定义 TUI 扩展,使用同样的输入模式。在仓库根目录启动,例如 macOS Apple Silicon 的 release 构建:
```sh
export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli"
pi -e ./apps/cli/plugins/pi/index.ts
```
其他平台将 `CROSSDESK_CLI` 改为原生可执行文件的绝对路径。在 Pi 中输入 `/crossdesk` 或 `/crossdesk 123456789`,按提示输入密码。安装要求与验证范围见 [Pi 扩展说明](apps/cli/docs/terminal-client.md#pi-扩展)。
CLI 当前仅提供 ID 连接与远程观看 / 输入,不提供 IP 直连、音频播放、文件传输或剪贴板同步。macOS arm64 已完成本地构建和连接验证;Linux / Windows 完整运行、SSH / Kitty 实机显示及真实 Pi TUI 的验收范围见 [验证记录](apps/cli/docs/validation.md)。平台构建配置与 npm 打包流程不代表全部平台已验收或包已发布。
[CLI 文档入口](apps/cli/README.md) · [终端使用与故障排查](apps/cli/docs/terminal-client.md) · [桥接协议](apps/cli/docs/bridge-protocol.md) · [npm 打包与分发](apps/cli/docs/npm-distribution.md)
<a id="mobile"></a>
## 移动端与鸿蒙
<a id="ios"></a>
## 原生 iOS / iPadOS 客户端
### iOS / iPadOS
仓库的 [`apps/ios`](apps/ios/README.md) 提供原生控制端,与桌面端使用同一套 MiniRTC 协议。它可以控制远端电脑;当前不提供将 iPhone / iPad 作为被控桌面的功能。
原生控制端支持触控与虚拟鼠标、多显示器、远端音频、文件收发和文本剪贴板。悬浮菜单的“剪贴板”按钮可主动发送本机文本,单次上限为 128 KiB。iPhone / iPad 当前不能作为被控桌面。
1. 按 [iOS 开发说明](apps/ios/README.md) 使用 Xcode 构建并签名,安装到 iOS 16+ 的真机。
2. 在首页输入对端 ID,点击连接并填写密码;成功连接后可从“最近连接”中重连。
3. 在设置中选择鼠标模式:**相对位置**像触控板一样移动光标,**绝对位置**将触摸位置直接映射到远端画面。
4. 点击悬浮图标展开菜单,可切换显示器、显示键盘、控制声音或发送文件。收到的文件位于应用的 `Documents/Received`,可通过传输状态旁的分享按钮导出。
当前支持接收远端文本到 iOS 剪贴板;界面暂未提供主动发送本机剪贴板的按钮。
当前支持单指点击、双指右键、长按拖动与捏合缩放;缩放后的双指平移用于移动视图。原生端与浏览器端分别维护,使用入口和手势可能不同。
[使用说明](docs/USER_GUIDE.md#ios) · [构建与签名](apps/ios/README.md)
<a id="android"></a>
## 原生 Android 控制端
### Android
[`apps/android`](apps/android/README.md) 提供独立 Android Studio / Gradle 工程,复用 MiniRTC 和桌面键鼠协议。支持连接电脑、H.264 / AV1 画面、声音、触控/外接键鼠、多屏切换和文本剪贴板。当前面向 Android 8.0+ arm64 设备,使用软件解码;尚未提供手机被控、文件传输和 iOS 全部附加功能。
原生控制端支持触控与外接键鼠、多屏切换、文件收发、文本剪贴板和会话画面设置。H.264 / AV1 优先使用硬件解码,不支持或运行失败时回退到软件解码;切换应用时优先通过画中画保持会话,画中画不可用时尝试前台服务保活。当前不提供手机被控功能。悬浮菜单中的“隐私屏”按钮可直接开启或关闭电脑本地遮罩,按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示;开启时与桌面端一致屏蔽电脑本地键鼠。
按 [Android 开发说明](apps/android/README.md) 构建并安装 APK,在首页填写电脑端的设备 ID 和密码即可连接。长按右键、双指滚动,横屏可扩大画面;切换到后台会结束会话。
[使用说明](docs/USER_GUIDE.md#android) · [源码构建](apps/android/README.md)
<a id="harmonyos"></a>
### 鸿蒙 HarmonyOS(开发版)
PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板使用原生 ArkUI 控制端,支持触控、外接键鼠、多屏、文本剪贴板和文件收发。PC 录屏与输入控制需要系统授权,真机和长期会话验收仍在进行。
[使用与限制](docs/USER_GUIDE.md#harmonyos) · [构建与签名](apps/harmony/README.md) · [PC 说明](apps/harmony/desktop-port/README.md) · [移动端功能](apps/harmony/MOBILE_PARITY.md)
<a id="windows-service"></a>
## Windows 锁屏与登录界面
## 无人值守与无头运行
**CrossDesk Service**(服务名 `CrossDeskService`)提供锁屏、登录、凭据输入及安全桌面的状态上报、`Ctrl+Alt+Del` 发送和键鼠输入转发。
安装版默认注册按需启动的服务,客户端启动时尝试启动它;未启用无人值守时,本机没有 CrossDesk 客户端进程后服务会自动退出。便携版可在提示窗口或“设置 → 锁屏控制服务”中安装服务,需要管理员权限。
Windows 的“开机自启”在当前用户登录后启动客户端。安装版使用以最高权限运行的登录计划任务,后续登录无需再次确认 UAC;便携版和 Debug 构建使用当前用户的普通启动项。升级安装版或手动启动新版时,会自动迁移指向该程序的旧启动项。关闭自启会删除相应注册。登录前远控需要另外启用下面的无人值守模式。
### 开机后尚未登录时远控
安装版支持独立的无人值守被控端。在完整安装目录中打开**管理员 PowerShell**,一次性启用:
```powershell
.\CrossDesk.exe --unattended-enable
# 等待联网注册后,查看独立设备 ID、密码和 online 状态
.\CrossDesk.exe --unattended-status
```
使用返回的 `device_id` 和 `password` 从另一台设备连接。首次启用时复制当前客户端的服务器和编码设置,单独生成无人值守凭据;它与普通客户端的设备 ID 不同,重启、注销和重新启用时保留。后续执行启用命令会复用机器配置,不会覆盖它。自建服务器的根证书需要安装到本地计算机的信任存储中。
启用后,`CrossDeskService` 改为系统启动时自动运行,无需 Windows 自动登录,也不需要保存 Windows 登录密码。服务以 SYSTEM 身份在当前控制台会话中启动无界面的被控进程,复用登录界面抓屏、键鼠及 `Ctrl+Alt+Del` 支持。用户登录、锁屏和关闭普通客户端不会停止它;注销、切换用户或进程崩溃后服务会重新拉起,期间连接可能中断,需要重新连接同一设备 ID。启动时网络尚未就绪会继续重试。
无人值守模式提供画面、键鼠、多屏切换和安全桌面控制;不提供声音、文件传输、剪贴板或隐私遮罩。便携版和 Debug 构建不能启用此模式。设置和凭据位于 `%ProgramData%\CrossDeskUnattended`,只允许 SYSTEM 和管理员访问;日志位于其 `logs` 子目录。
```powershell
# 停止无人值守被控端,并恢复服务按需启动
.\CrossDesk.exe --unattended-disable
```
关闭普通“开机自启”不会关闭无人值守模式。禁用或卸载服务会取消无人值守启动,但保留受保护的机器配置;若需彻底清除凭据,在服务停止后由管理员删除该目录。
<details>
<summary>手动部署与服务管理命令</summary>
保留完整安装目录;自行部署构建产物时,将 `CrossDesk.exe`、`crossdesk_service.exe`、`crossdesk_session_helper.exe` 及同次构建输出的全部 `.dll` 放在同一目录。在该目录打开管理员 PowerShell,按需执行:
```powershell
.\CrossDesk.exe --service-install
.\CrossDesk.exe --service-start
.\CrossDesk.exe --service-status
.\CrossDesk.exe --service-ping
# 停止或卸载服务
.\CrossDesk.exe --service-stop
.\CrossDesk.exe --service-uninstall
```
</details>
出现“远端 Windows 服务不可用”时,检查被控电脑的安装和服务状态;普通桌面连接仍可使用,受保护界面的控制能力会受限。
- **Windows:** CrossDesk Service 支持锁屏、登录和安全桌面控制。安装版可额外启用登录前远控,使用独立设备 ID 和密码;普通“开机自启”仅在用户登录后启动客户端。功能限制及启停命令见 [Windows 无人值守指南](docs/USER_GUIDE.md#windows-service)。
- **Linux:** 可复用现有桌面,也可创建独立 Xvfb / Xfce 会话,操作方法见 [Linux 无头运行](docs/HEADLESS.md)。
<a id="self-hosting"></a>
## 自托管服务器
## 自托管
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
可自行部署信令与 TURN 中继服务,并在客户端“设置 → 常规 → 自托管配置”中启用。控制端与被控端需要接入同一服务。
- [自托管指南](docs/SELF_HOSTING.md):当前 Compose 部署入口、客户端设置、证书信任与排查。
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server):服务端源码与发布配置。
- [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client):浏览器端源码与自托管说明。
[部署指南](docs/SELF_HOSTING.md) · [客户端配置](docs/USER_GUIDE.md#self-hosting) · [服务端源码](https://github.com/kunkundi/crossdesk-server) · [Web 客户端部署](https://github.com/kunkundi/crossdesk-web-client)
## 开发与反馈
<a id="development"></a>
- [源码构建](docs/BUILD.md):Windows、macOS、Linux、可选编译参数与打包。
- [iOS 开发说明](apps/ios/README.md):原生端构建和真机验证。
- [Android 开发说明](apps/android/README.md):APK 构建、安装与模拟器验证。
- [GUI 架构](docs/gui-architecture.md):桌面 UI、平台实现与工程结构。
- [常见问题](docs/FAQ.md):连接失败、黑屏、文件位置与编译问题。
- [提交问题](https://github.com/kunkundi/crossdesk/issues):请附上两端系统、客户端版本、连接方式和可复现步骤。
## 构建与反馈
- [终端客户端 CLI](apps/cli/README.md):独立构建、终端使用、Pi 扩展、桥接协议和 npm 打包。
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
桌面端的 `CROSSDESK_UPDATE_PUBLIC_KEY` 是可选编译参数,用于验证应用内升级清单的 Ed25519 签名。不配置仍可正常编译、运行和检查版本,但更新只能手动下载;需要应用内自动安装更新时,须配置与发布私钥匹配的公钥。配置方式见 [更新公钥](docs/BUILD.md#update-public-key),密钥生成与发布流程见 [桌面端应用内升级](docs/UPDATER.md)。
<a id="sponsor"></a>
## 支持 CrossDesk
如果 CrossDesk 对你有帮助,欢迎通过以下平台赞助,支持项目的持续开发、跨平台测试与日常维护。感谢你的支持!
[爱发电](https://afdian.com/a/kunkundi) · [Ko-fi](https://ko-fi.com/kunkundi) · [GitHub Sponsors](https://github.com/sponsors/kunkundi)
## 致谢与许可
感谢 [HelloGitHub](https://hellogithub.com/)、[阮一峰的科技爱好者周刊](https://github.com/ruanyf/weekly) 与 [LinuxDo](https://linux.do) 社区的推荐、交流与支持。
CrossDesk 使用 [GPL-3.0](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
Copyright (c) 2026 JUNKUN DI.
CrossDesk 使用 [GPL-3.0-only](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
### Code signing policy
+184 -208
View File
@@ -1,25 +1,85 @@
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
<h1>CrossDesk</h1>
<p>A lightweight, cross-platform remote desktop for computers, browsers, and iPhone / iPad.</p>
<img src="docs/images/crossdesk-banner.svg" width="420" alt="CrossDesk logo with the icon on the left and a two-tone wordmark on the right" />
<p>A lightweight, cross-platform remote desktop for computers, browsers, mobile devices, and terminals, with DeskHub for viewing and controlling multiple desktops.</p>
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
</div>
<div align="center">
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android-blue)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20Android%20%7C%20HarmonyOS-blue?style=flat)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk?style=flat)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/kunkundi/crossdesk/build.yml?style=flat&label=build)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=flat&label=stars)](https://github.com/kunkundi/crossdesk/stargazers)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF6433?logo=kofi&logoColor=white)](https://ko-fi.com/kunkundi)
[![GitHub Sponsors](https://img.shields.io/badge/GitHub_Sponsors-C93C45?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/kunkundi)
[![AFDIAN](https://img.shields.io/badge/AFDIAN-946CE6?logo=afdian&logoColor=white)](https://afdian.com/a/kunkundi)
</div>
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS / Android clients for controlling computers. Built on [MiniRTC](https://github.com/kunkundi/minirtc), it supports real-time video and audio, keyboard and mouse control, file transfer, and self-hosting. The project is under active development.
CrossDesk connects your computers from desktop, browser, mobile, and terminal clients, with **DeskHub** for viewing and controlling multiple devices together. It is built on [MiniRTC](https://github.com/kunkundi/minirtc) and supports self-hosting.
> This guide follows the current repository source. Desktop screenshots render the current Slint UI with sample IDs, passwords, online statuses, and recent devices; thumbnails show public pages. Check each release's notes for the features and requirements of its binaries. Native iOS build and signing instructions are below.
[Features](#features) · [Download and platforms](#download) · [Quick start](#quick-start) · [Terminal CLI](#cli) · [Direct IP](#lan-direct) · [Preview](#preview) · [User guide](docs/USER_GUIDE_EN.md) · [Self-hosting](#self-hosting) · [Build and feedback](#development) · [Sponsor](#sponsor)
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english)
<a id="features"></a>
## Features
- **Cross-platform access:** control Windows, macOS, and Linux desktops from computers, browsers, or native iOS / Android clients. HarmonyOS PC and mobile clients are in development.
- **Direct IP over LAN:** connect desktop clients using an IP address, port, and six-character password without a cloud signaling service. Each local IP has its own port; passwords can be remembered and recent devices are separated by mode.
- **Terminal controller:** view and control remote desktops by device ID, with ANSI / Kitty rendering, zoom and pan, persistent local command mode, a Pi extension, and a build independent of the desktop GUI.
- **DeskHub:** live multi-device previews, groups, enlarged control, group input, and batch file distribution.
- **Video and audio:** H.264 / AV1, hardware codecs, remote audio, and in-session quality and frame-rate controls. Capabilities depend on the platform and device.
- **Everyday controls:** monitor switching, keyboard and mouse input, shortcuts, text clipboard, and file transfer.
- **Deployment:** direct P2P connections, TURN relay, SRTP media encryption, self-hosting, Windows access before sign-in, and Linux headless operation.
<a id="download"></a>
## Download and platform support
Get a package matching your OS and CPU architecture from the [website](https://www.crossdesk.cn/) or [GitHub Releases](https://github.com/kunkundi/crossdesk/releases), or open the [Web Client](https://web.crossdesk.cn/) directly.
| Platform | Support and installation |
| --- | --- |
| Windows | **Control + host**. Windows 10+, x64; `.exe` installer, with portable builds also supported |
| macOS | **Control + host**. macOS 14.0+; x64 / arm64 `.pkg` for Intel / Apple Silicon |
| Linux | **Control + host**. Ubuntu 20.04+, glibc 2.31 baseline; amd64 / arm64 `.deb` |
| iOS / iPadOS | **Control only**. iOS 16.0+; sign the unsigned release app yourself, or build and install with Xcode |
| Android | **Control only**. Android 8.0+, arm64; install the signed APK or build from source |
| HarmonyOS PC (development) | **Control + host**. API 26, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing |
| HarmonyOS phones / tablets (development) | **Control only**. API 20+, arm64; build and sign a HAP with DevEco; physical-device validation is ongoing |
| Web | **Control only**. A WebRTC-capable browser; no client installation on the controlling device |
| Terminal CLI (development) | **Control only**. Build targets: macOS / Linux (x64, arm64) and Windows (x64). Build from source; see the [CLI guide](apps/cli/README.md) (Chinese) for platform validation status |
Requirements follow the current source and build configuration. Check the release notes for the downloaded package. Client capabilities are summarized under [Terminal CLI](#cli) and [Mobile and HarmonyOS](#mobile).
- **Linux:** use `crossdesk-linux-…deb` for an existing desktop, or `crossdesk-virtual-desktop-xfce-…deb` for a server needing a separate Xfce desktop. Choose one. See [installation details](docs/USER_GUIDE_EN.md#installation) and [headless operation](docs/HEADLESS_EN.md).
- **macOS:** grant Screen Recording and Accessibility when prompted on first launch. These enable desktop capture and remote keyboard/mouse input. See [permissions](docs/USER_GUIDE_EN.md#installation).
- **iOS / HarmonyOS:** follow the [iOS](apps/ios/README.md) or [HarmonyOS](apps/harmony/README.md) (Chinese) signing and build guide. Unsigned artifacts cannot be installed directly.
<a id="quick-start"></a>
## Quick start
### Connect by device ID
1. **Prepare the host.** Install and run CrossDesk on the computer to control, select ID mode, and wait for the server-connected status, then find its ID and password under **Local Desktop**.
2. **Connect.** On another computer, enter the host's 9-digit ID and click **Connect**. Native mobile clients and the [Web Client](https://web.crossdesk.cn/) can also connect.
3. **Verify the password.** Enter the host's current connection password and wait for the desktop to appear. On desktop, select **Remember password** to reconnect later from **Recent Connections**.
For self-hosting, both ends must use the same service. See the [user guide](docs/USER_GUIDE_EN.md#quick-start) for connection, password, and browser controls, or the [FAQ](docs/FAQ.md#english) for failed connections, blank video, and input problems.
<a id="lan-direct"></a>
### Connect directly over LAN
1. **Switch modes.** Run a desktop client with direct IP support on both computers. Click the top-left mode button to select **Direct IP mode**. If the button is hidden, select **Enable direct IP** under **Settings → General → Network** and save.
2. **Enter the address.** On the controller, enter the host's displayed `IP:port`, for example `192.168.1.20:52888` or `[2001:db8::20]:52888`, and click **Connect**. Omitting the port uses **52888**; use the actual port shown on the host.
3. **Enter the password.** Use the host's **six-character ASCII letter/digit password** shown in Direct IP mode. You can select **Remember password**. Switching modes also switches the recent device list.
Both computers must be reachable over the LAN. Internet access may be available or absent; direct sessions do not depend on a cloud signaling service. The main window lets you select the local IP, edit its port, and change the password. **Listen on all IP interfaces** is enabled by default for IPv4, IPv6, VPN, and other local interfaces on a shared port; change it under **Settings → General → Network**. Disabling **Enable direct IP** stops listeners, disconnects direct IP sessions, and hides the mode button. See the [direct IP guide](docs/USER_GUIDE_EN.md#lan-direct).
<a id="preview"></a>
@@ -27,247 +87,167 @@ CrossDesk connects Windows, macOS, and Linux desktops, with browser and native i
### Desktop client
The main window brings together your device ID, connection password, remote connection entry, and recent devices.
Local credentials, remote connections, and recent devices share the main window. The top-left controls switch connection modes and open DeskHub.
<p align="center">
<img src="docs/images/desktop-main-en.png" width="800" alt="Current desktop UI example showing a connected server and three online devices with page thumbnails" />
<img src="docs/images/desktop-main-en.png" width="800" alt="CrossDesk ID mode with the mode switch, local ID, remote connection controls, and recent devices; demonstration data" />
</p>
### Web client
### Direct IP mode
Enter a remote device ID and password in your browser without installing the desktop app on the controlling device.
Select the local IP, edit its port and password, and connect using the other desktop's address. Recent LAN devices have their own list below.
<p align="center">
<img src="docs/images/web-client.png" width="800" alt="Current CrossDesk Web Client connection page in Chinese" />
<img src="docs/images/desktop-lan-en.png" width="800" alt="CrossDesk Direct IP mode with local IP and port editing, a six-character password, remote address input, and recent LAN devices; demonstration data" />
</p>
See the [screenshot notes](docs/images/README.md) for image sources and refresh instructions.
<a id="deskhub"></a>
## Features
### DeskHub
| Capability | Current support |
| --- | --- |
| Cross-platform control | Windows / macOS / Linux desktop control; browser and native iOS / Android controllers |
| Live video and audio | H.264 / AV1, hardware codecs, and remote audio; adjust quality, 30 / 60 fps, and video preference during a session |
| Devices and displays | Recent connections, device aliases, session tabs, and remote display switching |
| Input and sharing | Keyboard/mouse input, remote cursor synchronization, shortcuts, text clipboard synchronization, and file transfer |
| Networking and deployment | Direct P2P connections, TURN relay and forced relay, SRTP media encryption, and self-hosted signaling/relay services |
| Privacy screen | Enable automatically when connected to, or toggle from the session toolbar; availability depends on the host OS and capture method |
| Windows protected desktops | CrossDesk Service forwards input on lock screens, sign-in screens, and secure desktops |
| Linux headless operation | Reuse existing desktops and applications or create separate Xvfb desktops; use the terminal console to check status and change passwords or settings; see [setup](docs/HEADLESS_EN.md) |
View devices by group, click a preview to enlarge it, and take control when needed. Batch actions include connecting, muting, and sending files.
Controls and capabilities vary by client. Desktop controls are described below; see the [iOS guide](apps/ios/README.md) for native mobile features.
<p align="center">
<img src="docs/images/desktop-deskhub-en.png" width="960" alt="DeskHub with six demonstration remote desktops and its management toolbar" />
</p>
<a id="download"></a>
## Download and install
Choose a package matching your OS and CPU architecture from [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) or the [website](https://www.crossdesk.cn/).
| Platform | Current source / CI baseline | Installation |
| --- | --- | --- |
| Windows | Windows 10+, x64 | `.exe` installer; portable builds are also supported |
| macOS | macOS 14.0+, Intel / Apple Silicon | x64 or arm64 `.pkg` |
| Linux | Ubuntu 20.04+, amd64 / arm64, glibc 2.31 baseline | Standard / Xfce virtual desktop `.deb` |
| iOS / iPadOS | iOS 16.0+, arm64 physical device | Native controller; build and sign with Xcode, or sign the unsigned CI app |
| Android | Android 8.0+, arm64 | Native controller; build an APK with Gradle ([guide](apps/android/README.md)) |
| Web | A WebRTC-capable browser | Open [web.crossdesk.cn](https://web.crossdesk.cn/) |
Linux provides two standalone packages per architecture. **Choose one**:
| Package | Intended use |
| --- | --- |
| `crossdesk-linux-<arch>-<version>.deb` (recommended) | Computers with an existing desktop; does not install Xfce or Xvfb |
| `crossdesk-virtual-desktop-xfce-linux-<arch>-<version>.deb` | Servers needing an independent desktop; APT additionally installs **Xfce, Xvfb and D-Bus session tools** |
To install the standard package, replace the example filename with your download (use arm64 for that architecture):
```bash
sudo apt install "./crossdesk-linux-amd64-<version>.deb"
```
For the virtual desktop package, install this file instead:
```bash
sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
```
Both contain the same complete application, replace each other and preserve user settings. The standard package can also create virtual desktops after you install the optional dependencies. Installing the virtual desktop package does not start a session or change the default desktop; see [Linux headless operation](docs/HEADLESS_EN.md) to start one. The app's update download button selects the installed package type. Upgrading an existing installation does not automatically remove previously installed Xfce components.
**First launch on macOS:** follow the app prompts to grant **Screen Recording** (called **Screen & System Audio Recording** on newer systems) and **Accessibility** under **System Settings → Privacy & Security**. Reopen the app if prompted. These permissions allow desktop capture and remote keyboard/mouse input respectively.
<a id="quick-start"></a>
## Quick start
### Connect from another computer
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the bottom status bar to show that the server is connected. Share the **Local ID** and **Password** shown under **Local Desktop** with the controller.
2. **Enter the remote ID.** On the controlling computer, enter the host's ID in **Remote Desktop → Remote ID**, then click **→**.
3. **Verify the password.** Enter the host's current connection password when prompted and confirm. Select **Remember password** if you want to save it.
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, edit its alias, or remove its record.
The eye button shows or hides the local password, the refresh button generates a new random password, and the pencil button lets you edit it. Passwords use **6 ASCII letters or digits**. Stay connected to the server when editing or refreshing a password. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
### Connect from a browser
1. Keep CrossDesk running and connected to the server on the host computer.
2. Open the [Web Client](https://web.crossdesk.cn/), enter the **Remote Device ID** and **Password**, and click **Connect**.
3. Use the page's display, mouse-mode, and keyboard controls during the session. Phone and tablet browsers can also connect.
For self-hosting, connect both sides to the same service. Browser deployment is documented in [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client).
Group control sends input to all connected devices; closing DeskHub disconnects all remote connections. See the [DeskHub user guide](docs/USER_GUIDE_EN.md#deskhub) for exact scopes and controls. Screenshots use demonstration data; more views and sources are in the [user guide](docs/USER_GUIDE_EN.md) and [screenshot notes](docs/images/README.md).
<a id="session"></a>
## Session controls
Switch devices using session tabs. Expand the control bar if it is collapsed, and hover over icons to see their tooltips.
| Control | Action |
| --- | --- |
| Display | Switch the remote monitor |
| Keyboard | Send shortcuts; Windows `Ctrl+Alt+Del` requires the remote service |
| Mouse | Enable / release remote mouse control |
| Speaker | Play / mute remote audio |
| Folder | Select a file to send and view transfer progress |
| Video settings | Adjust video quality, capture frame rate, and video preference during the current session |
| Network statistics | Inspect traffic, packet loss, frame rate, resolution, direct / relay mode, and encryption status |
| Privacy screen | Enable or disable the host's privacy screen; hover to see its status and available actions |
| Fullscreen | Enter / exit fullscreen |
| Disconnect | End the current remote session |
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
### Adjust video during a session
After connecting, open **Video settings** (the sliders icon) in the toolbar. Choose low / medium / high quality, **30 / 60 fps**, and frame-rate priority / quality priority / balanced adaptation. Changes are sent to the current host without reconnecting; actual results depend on the network, device, and encoder. For older hosts that do not support live video settings, the menu explains the limitation and disables the options.
<p align="center">
<img src="docs/images/desktop-stream-video-settings-en.png" width="800" alt="Current session video menu with quality, capture frame rate, and video preference; the background is a public page used for demonstration" />
</p>
<a id="settings"></a>
## Client settings and updates
## Session controls and settings
Open **☰ → Settings** in the top-right corner and click **OK** to save. Configure codecs and connections before connecting; some settings are disabled during an active session. Scroll down for self-hosting, startup behavior, and the file save path. Adjust quality, frame rate, and video preference from the remote session toolbar.
Remote sessions support monitor switching, video quality and frame-rate controls, audio, text clipboard synchronization, and file transfer. The toolbar also provides network statistics and privacy screen controls. Desktop settings configure direct IP availability, codecs, relay, self-hosting, and the DeskHub video budget.
<p align="center">
<img src="docs/images/desktop-settings-en.png" width="720" alt="Current client settings with codec, hardware codecs, TURN, forced relay, and automatic privacy screen options" />
</p>
- [Session controls](docs/USER_GUIDE_EN.md#session): toolbar, text clipboard, received files, and privacy screens.
- [Client settings and updates](docs/USER_GUIDE_EN.md#settings): general options, DeskHub video settings, and version checks.
- [DeskHub technical guide](docs/deskhub.md) (Chinese): preview subscriptions, bandwidth allocation, protocol, and compatibility.
| Setting | Purpose |
<a id="cli"></a>
## Terminal client CLI
`crossdesk-cli` connects to a host by its 9-digit device ID and starts in read-only, silent mode. Standard terminals use ANSI character rendering; terminals supporting the Kitty graphics protocol can display pixel graphics. The CLI supports monitor selection, quality settings, detail zoom, panning, and optional keyboard/mouse control. It builds independently of the desktop GUI. The host still needs CrossDesk and a capturable graphical session, and the signaling service must support temporary `cli` identities.
### Build and connect
Install Git, Xmake 3.1.1 or later, a C++17 toolchain, and the platform dependencies. The following example uses a macOS / Linux shell; Windows Git Bash / Developer PowerShell steps are in the [CLI build guide](apps/cli/docs/terminal-client.md#构建与安装) (Chinese).
```sh
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
./apps/cli/scripts/prepare-deps.sh
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
xmake build crossdesk-cli
xmake run crossdesk-cli doctor --json
xmake run crossdesk-cli 123456789
```
For an existing checkout, run the dependency preparation and subsequent commands from the repository root. Replace `123456789` with the host's ID; the password prompt hides your input. `doctor` reports the local environment without connecting. The repository builds the desktop client by default; this configuration builds only the CLI. Set `CROSSDESK_BUILD_DESKTOP=y` to also build the desktop client. Binaries are placed in `build/<platform>/<arch>/release/`.
An ANSI overview is limited by the terminal's character grid. Add `--quality high --zoom native`, or press `d` in the viewer, to inspect small text. `--renderer kitty` requires a terminal supporting that protocol. See the [terminal guide](apps/cli/docs/terminal-client.md) (Chinese) for rendering, mouse reporting, SSH, and terminal compatibility.
### Control, navigation, and exit
| Mode / key | Behavior |
| --- | --- |
| Language | 中文, English, Русский |
| Capture Method (Windows) | Auto / DXGI / WGC / GDI; choose Auto for automatic headless adaptation |
| Video Encode Format | H.264 / AV1; hardware codec availability depends on the platform, device, and build options |
| Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails |
| Force Relay | Require relay connections when TURN is enabled; turning TURN off also disables this option |
| Save remote screen previews | On by default. When disabled, the thumbnail area shows the remote device’s platform and existing previews are cleared; connection records and passwords are preserved |
| Privacy screen on connect | Automatically enable privacy when this computer is controlled remotely; the controller can toggle it from the session toolbar |
| Self-Hosted Config | Set the server address and signaling port, then enable the checkbox |
| Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
| File Save Path | Choose where received files are saved |
| Read-only mode | `c` enables remote control; `q` or `Ctrl+C` exits the CLI |
| Control mode | Keyboard/mouse input goes to the host; `Ctrl+]` enters local command mode and pauses remote input |
| Local command mode | Stays active after commands; `c` selects whether control resumes when you leave; `q` exits the CLI |
| `Esc` / `Ctrl+C` | Manually leave local command mode and return to the selected control or read-only mode |
| `+` / `=` / `-` | Zoom in / out in read-only or local command mode |
| `d` / `f` | Switch to native detail / full-desktop view in read-only or local command mode; clicking in read-only mode also opens detail at that point |
| Arrow keys / `i j k l` | Pan in read-only or local command mode: `i` up, `j` left, `k` down, `l` right |
### Check for updates
Zooming, panning, or pressing `c` does not end `Ctrl+]` mode. The status bar shows `CONTROL PAUSED` / `READ ONLY` to indicate whether control will resume. In control mode, ordinary `c`, `q`, and `Ctrl+C` go to the host, so enter local command mode first for local actions. Pressing `Ctrl+]` again also leaves local mode and forwards that shortcut if control resumes.
The client checks for updates in the background after launch and automatically opens the update dialog when a new version is found, showing release notes and a link to the download page. After you dismiss the dialog, background checks will not reopen it during the current run, while the main window menu keeps its update indicator. Restarting the client shows the dialog again if an update is still available. Open **☰ → About** to view the version. When an update is available, click **Release notes** below the new version to reopen the update dialog, or click **Check for updates** to check manually.
### Pi extension and feature scope
<p align="center">
<img src="docs/images/desktop-about-en.png" width="720" alt="About dialog with the Check for updates button; version and check result are demonstration data" />
</p>
The CLI includes a Pi custom TUI extension with the same input modes. Launch it from the repository root; for example, with a macOS Apple Silicon release build:
```sh
export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli"
pi -e ./apps/cli/plugins/pi/index.ts
```
On other platforms, set `CROSSDESK_CLI` to the absolute path of the native executable. Enter `/crossdesk` or `/crossdesk 123456789` in Pi and follow the password prompt. See the [Pi extension guide](apps/cli/docs/terminal-client.md#pi-扩展) (Chinese) for requirements and validation scope.
The CLI currently provides ID-based viewing and input, without direct IP connections, audio playback, file transfer, or clipboard synchronization. Local builds and remote connections have been validated on macOS arm64. See the [validation record](apps/cli/docs/validation.md) (Chinese) for the remaining Linux / Windows, SSH / Kitty terminal, and actual Pi TUI checks. Platform build configurations and npm packaging do not establish that every platform is validated or that packages have been published.
[CLI documentation](apps/cli/README.md) · [Terminal usage and troubleshooting](apps/cli/docs/terminal-client.md) · [Bridge protocol](apps/cli/docs/bridge-protocol.md) · [npm packaging and distribution](apps/cli/docs/npm-distribution.md) (detailed guides in Chinese)
<a id="mobile"></a>
## Mobile and HarmonyOS
<a id="ios"></a>
## Native iOS / iPadOS client
### iOS / iPadOS
[`apps/ios`](apps/ios/README.md) contains a native controller using the same MiniRTC protocol as the desktop client. It controls remote computers; it does not expose an iPhone or iPad as a remotely controlled desktop.
The native controller supports touch and a virtual mouse, multiple monitors, remote audio, file transfers, and text clipboard. Tap **剪贴板** (Clipboard) in the floating menu to send local text, up to 128 KiB per transfer. iPhone and iPad cannot currently act as remote hosts. The native interface uses Chinese labels.
1. Follow the [iOS guide](apps/ios/README.md) to build and sign the app with Xcode, then install it on an iOS 16+ physical device.
2. Enter a remote ID on the home screen, connect, and enter its password. Successful connections become available in the recent devices list.
3. Choose a mouse mode in Settings: **Relative Position** works like a trackpad; **Absolute Position** maps touches directly to the remote screen.
4. Tap the floating icon to expand the menu, then switch displays, open the keyboard, toggle audio, or send files. Received files are stored in `Documents/Received` and can be exported using the share button beside the transfer status.
[User guide](docs/USER_GUIDE_EN.md#ios) · [Build and signing](apps/ios/README.md)
The app currently receives remote text into the iOS clipboard. Its UI does not yet expose a button to send the local clipboard.
<a id="android"></a>
Gestures include one-finger click, two-finger right-click, long-press drag, and pinch-to-zoom. When zoomed in, two-finger panning moves the viewport. The native and browser clients are maintained separately, so their controls and gestures may differ. The current native UI uses Chinese labels.
### Android
The native controller supports touch and external keyboards/mice, monitor switching, file transfers, text clipboard, and video settings. H.264 / AV1 use hardware decoding when available, with software fallback when unsupported or when decoding fails. Active sessions use Picture in Picture when switching apps, with a foreground service as a fallback when Picture in Picture is unavailable. Phone-as-host is not currently supported. The floating menu’s **Privacy screen** button toggles the computer’s local cover directly, labels the host-confirmed on/off state without changing colors, and shows inline feedback only for errors or unavailable status. Enabling privacy also blocks local keyboard/mouse input, matching the desktop controller.
[User guide](docs/USER_GUIDE_EN.md#android) · [Build from source](apps/android/README.md) (Chinese)
<a id="harmonyos"></a>
### HarmonyOS (development)
The PC client acts as both controller and host, sharing the desktop UI and DeskHub. Phones and tablets use a native ArkUI controller with touch, external input devices, monitors, text clipboard, and file transfers. PC screen capture and input control require system authorization. Physical-device and long-session validation are ongoing.
[Usage and limits](docs/USER_GUIDE_EN.md#harmonyos) · [Build and signing](apps/harmony/README.md) · [PC guide](apps/harmony/desktop-port/README.md) · [Mobile features](apps/harmony/MOBILE_PARITY.md) (platform guides in Chinese)
<a id="windows-service"></a>
## Windows lock screens and sign-in
## Unattended and headless access
**CrossDesk Service** (`CrossDeskService`) reports protected desktop states and forwards `Ctrl+Alt+Del`, keyboard, and mouse input on lock screens, sign-in screens, credential prompts, and secure desktops.
The installer registers an on-demand service by default. CrossDesk tries to start it on launch. Unless unattended mode is enabled, the service exits when no local CrossDesk client is running. Portable builds offer service installation in the prompt or **Settings → Lock Screen Service**, with administrator privileges.
On Windows, **Auto Start** launches the client after the current user signs in. Installed release builds use a logon scheduled task with highest privileges, so subsequent logons need no UAC confirmation; portable and Debug builds use a normal per-user startup entry. Upgrading the installed build or launching the new version manually migrates a legacy startup entry pointing to that executable. Turning Auto Start off removes its registration. Remote access before sign-in requires the separate unattended mode below.
### Remote access before Windows sign-in
The installed release supports a separate unattended host. Open an **administrator PowerShell** in the complete installation directory and enable it once:
```powershell
.\CrossDesk.exe --unattended-enable
# After network registration, show its separate device ID, password and online state
.\CrossDesk.exe --unattended-status
```
Connect from another device using the returned `device_id` and `password`. First-time setup copies the current client's server and codec preferences and creates separate credentials. This ID differs from the GUI client's ID and persists across reboots, logoffs and re-enabling. Subsequent enable commands retain the machine configuration. Self-hosted server root certificates must be trusted in the Local Computer certificate store.
Once enabled, `CrossDeskService` starts automatically at system boot. Windows automatic login and saved Windows login passwords are unnecessary. The service launches a windowless SYSTEM host in the active console session and reuses secure-desktop capture, keyboard/mouse input and `Ctrl+Alt+Del`. Signing in, locking Windows or closing the ordinary client does not stop it. Logoff, user switching or a host crash causes the service to restart the host; the connection may drop, and you can reconnect using the same ID. Network connections retry if networking is unavailable during boot.
Unattended mode supports screen viewing, keyboard/mouse input, monitor switching and secure desktops. Audio, file transfer, clipboard and privacy screens are unavailable. Portable and Debug builds cannot enable it. Configuration and credentials reside in `%ProgramData%\CrossDeskUnattended`, accessible only to SYSTEM and administrators; logs are in its `logs` subdirectory.
```powershell
# Stop the unattended host and restore on-demand service startup
.\CrossDesk.exe --unattended-disable
```
Turning off ordinary Auto Start does not disable unattended mode. Disabling or uninstalling the service disarms unattended startup but retains the protected machine profile. To erase its credentials, stop the service and delete that directory as an administrator.
<details>
<summary>Manual deployment and service commands</summary>
Keep the complete installation directory. For manual deployment, place `CrossDesk.exe`, `crossdesk_service.exe`, `crossdesk_session_helper.exe`, and all `.dll` files from the same build together. Open an administrator PowerShell in that directory and run the commands you need:
```powershell
.\CrossDesk.exe --service-install
.\CrossDesk.exe --service-start
.\CrossDesk.exe --service-status
.\CrossDesk.exe --service-ping
# Stop or uninstall the service
.\CrossDesk.exe --service-stop
.\CrossDesk.exe --service-uninstall
```
</details>
If **Remote Windows service unavailable** appears, check installation and service status on the host. Ordinary desktop connections remain available, while control of protected screens is limited.
- **Windows:** CrossDesk Service supports lock screens, sign-in, and secure desktops. Installed builds can enable access before sign-in with a separate device ID and password; ordinary **Auto Start** runs after a user signs in. See the [Windows unattended guide](docs/USER_GUIDE_EN.md#windows-service) for capabilities, limits, and commands.
- **Linux:** reuse an existing desktop or create an independent Xvfb / Xfce session. See [Linux headless operation](docs/HEADLESS_EN.md).
<a id="self-hosting"></a>
## Self-hosting
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → Self-Hosted Config**. Enable the same server configuration on both the controller and host.
Host your own signaling and TURN services, then enable them under **Settings → General → Self-Hosted Config**. Both the controller and host must use the same service.
- [Self-hosting guide](docs/SELF_HOSTING_EN.md): current Compose deployment, client settings, certificate trust, and troubleshooting.
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server): server source and release configuration.
- [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client): browser source and deployment instructions.
[Deployment guide](docs/SELF_HOSTING_EN.md) · [Client setup](docs/USER_GUIDE_EN.md#self-hosting) · [Server source](https://github.com/kunkundi/crossdesk-server) · [Web client deployment](https://github.com/kunkundi/crossdesk-web-client)
## Development and feedback
<a id="development"></a>
- [Build from source](docs/BUILD_EN.md): Windows, macOS, Linux, optional flags, and packaging.
- [iOS development](apps/ios/README.md): native build and physical-device checks.
- [Android development](apps/android/README.md): APK builds, installation, and emulator checks.
- [GUI architecture](docs/gui-architecture.md): desktop UI and platform organization.
- [FAQ](docs/FAQ.md#english): connection failures, blank video, received files, and build issues.
- [Report an issue](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
## Build and feedback
- [Terminal client CLI](apps/cli/README.md) (Chinese): independent builds, terminal usage, Pi extension, bridge protocol, and npm packaging.
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
- [Connection architecture](docs/connection-architecture.md) (Chinese): discovery, authentication, negotiation, data paths, and lifecycle for ID and direct IP modes.
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
- [Report an issue or suggestion](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
The optional desktop build parameter `CROSSDESK_UPDATE_PUBLIC_KEY` verifies Ed25519 signatures on in-app update manifests. Without it, the app still builds, runs, and checks for new versions, but updates require manual download. In-app update installation requires a public key matching the release signing private key. See [Update public key](docs/BUILD_EN.md#update-public-key) for configuration and the [desktop updater guide](docs/UPDATER.md) (Chinese) for key generation and publishing.
<a id="sponsor"></a>
## Support CrossDesk
If CrossDesk is useful to you, consider sponsoring its continued development, cross-platform testing, and maintenance through one of the platforms below. Thank you for your support!
[Ko-fi](https://ko-fi.com/kunkundi) · [GitHub Sponsors](https://github.com/sponsors/kunkundi) · [AFDIAN](https://afdian.com/a/kunkundi)
## Acknowledgements and license
Thanks to [HelloGitHub](https://hellogithub.com/), [Ruanyf Weekly](https://github.com/ruanyf/weekly), and the [LinuxDo](https://linux.do) community for featuring CrossDesk and contributing feedback.
CrossDesk is licensed under [GPL-3.0](LICENSE). See the [Privacy Policy](PRIVACY.md#english).
Copyright (c) 2026 JUNKUN DI.
CrossDesk is licensed under [GPL-3.0-only](LICENSE). See the [Privacy Policy](PRIVACY.md#english).
### Code signing policy
@@ -277,7 +257,3 @@ CrossDesk uses SignPath.io to sign official Windows releases built from this rep
- **Committers and reviewers:** [kunkundi](https://github.com/kunkundi)
- **Approvers:** [kunkundi](https://github.com/kunkundi)
## Native Android controller
The independent [Android project](apps/android/README.md) builds an APK for Android 8.0+ arm64 devices. It reuses MiniRTC and the desktop input protocol for remote video/audio, touch and external keyboard/mouse input, display switching, and text clipboard transfer. H.264 and AV1 currently use software decoding. Phone-as-host, file transfer, and full iOS feature parity are not included. See the [Android build guide](apps/android/README.md) for setup and validation.
+11 -7
View File
@@ -7,12 +7,14 @@
- 官方或自托管 WSS 信令服务器;授权后进入前台即自动注册或登录本机身份,首页实时显示连接、重连及失败状态。发起桌面连接时复用该身份;切换服务器会重新登录,撤回授权会关闭连接。已有远程会话优先通过系统画中画保持连接,画中画不可用时由带连接通知的前台服务兜底;首页空闲信令在后台关闭。
- 优先使用 MediaCodec 硬件解码 H.264 / AV1,硬件缓冲区直接提交 Surface;设备无对应硬件、分辨率超限或运行失败时,按原编码格式回退到 OpenH264 / dav1d。软件输出由独立显示线程将 NV12 转 RGBA;只保留最新待显示帧。MiniRTC 原有的通用压缩帧解码队列保持不变。
- 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指捏合以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
- “设置 → 屏幕方向 → 连接建立后锁定横屏”默认关闭并保存在本机。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受此选项影响。退出会话恢复首页方向,返回后台会话沿用该会话的方向设置。已移除检测手机姿态后解除初始横屏的逻辑。
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
- 悬浮菜单提供“隐私屏”直接开关,无需进入详情页;按钮按电脑确认的状态显示“隐私屏已开启”或“隐私屏已关闭”,开启与关闭使用相同配色,正常切换不显示额外文字,仅在异常时显示提示。开启时与桌面端一致屏蔽电脑本地键鼠;声明控制能力后可使用电脑的连接时自动开启设置。状态超过 5 秒未更新时停止显示成功,指令 15 秒未确认时提示超时;后台保活与画中画恢复沿用同一会话状态。
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意。远程会话在切换应用时自动使用系统画中画,不可用时自动使用通知保活。
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
@@ -24,7 +26,7 @@
- 可选保存远程画面预览:默认关闭,经确认开启后,从下一次连接起保存一张 640×360 预览,用于最近连接卡片。支持清除预览,关闭选项或撤回授权会清除已有图片。
- “关于 CrossDesk”对齐 iOS 的版本、软件许可、开源组件及源码与构建说明页面,包含 OpenFEC 告知、组件详情和可离线选择复制的完整许可;版本号读取当前 APK,构建时记录应用与 MiniRTC 的基础版本。
当前不包含安卓被控端、隐私屏或 iOS 全部附加功能。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
当前不包含安卓被控端或 iOS 全部附加功能。硬件能力取决于设备和编码格式,软件回退时高分辨率或高帧率可能增加功耗;可通过悬浮菜单的画面设置降低画质或采集帧率。暂只构建 `arm64-v8a`,不支持 x86 模拟器或 32 位设备。
## 本机更新提示
@@ -70,7 +72,7 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP
首次构建会下载并从源码编译依赖,耗时取决于网络。后续编译复用缓存。Android 直接编译 `deps/submodules/minirtc` 中的源码;构建产物和依赖缓存位于 `.native/`,Xmake 工程配置位于 `native/.xmake/`,与桌面和 iOS 分开保存:
- Gradle 9.3.1、AGP 9.1.0、NDK r28c 固定在工程中。
- Xmake 包仓库固定到 `eda39de3fb99b420c168f1ab9ab2d1791e11b662`。
- Xmake 依赖仓库沿用 PC 端策略:使用默认仓库或调用环境指定的仓库,不固定或校验仓库提交。Android 编译产物和安装包缓存仍独立保存。
- `scripts/build_native.py` 调用独立的 `native/xmake.lua`,直接包含 MiniRTC 子模块的构建定义。构建过程不复制 MiniRTC 源码,也不应用补丁。
- MiniRTC 源码内的 Android 分支启用 OpenH264、SVT-AV1 和 dav1d 软件编解码及依赖配方,兼容 OpenFEC 的 Android 头文件,并关闭 UPnP。SVT-AV1 使用内置 ARM CPU 能力检测;当前应用作为控制端只接收视频,库同时提供 AV1 编码能力。TLS 校验位于 MiniRTC 内部。STUN、TURN UDP/TCP 与直接连接仍由 MiniRTC 提供。
- MiniRTC 的 `thirdparty/openssl/xmake.lua` 统一维护 iOS 和 Android 配方;Android 显式使用 NDK 的归档索引工具,避免 macOS `ranlib` 损坏 ELF 静态库。
@@ -96,7 +98,7 @@ git push origin android-v1.6.1-20261010
已有 Release 需要补传服务器或刷新下载信息时,可手动运行 `Update version.json from Release` 并填写已有 `source_tag`,无需重新构建。
工作流使用 Ubuntu 24.04、JDK 17、Python 3.13、SDK 36、Build Tools 36.0.0、NDK r28c,并使用最低支持的 Xmake 3.1.1 验证构建;本地允许使用更新的 Xmake。Gradle 使用工程内的 Wrapper,原生构建器固定 Xmake 包仓库版本。SDK/NDK 使用独立目录缓存,按宿主系统、架构和工具链版本区分;命中缓存时跳过 SDK 安装,未命中时在安装完成后立即保存,即使后续 APK 构建失败,下次也可复用。Xmake 可执行环境、固定版本的包仓库和 pip 下载也有各自的缓存;Ubuntu 系统工具只安装缺失项。
工作流使用 Ubuntu 24.04、JDK 17、Python 3.13、SDK 36、Build Tools 36.0.0、NDK r28c,并使用最低支持的 Xmake 3.1.1 验证构建;本地允许使用更新的 Xmake。Gradle 使用工程内的 Wrapper,原生构建器沿用 PC 端的 Xmake 仓库选择方式。SDK/NDK 使用独立目录缓存,按宿主系统、架构和工具链版本区分;命中缓存时跳过 SDK 安装,未命中时在安装完成后立即保存,即使后续 APK 构建失败,下次也可复用。Xmake 可执行环境和 pip 下载也有各自的缓存;Ubuntu 系统工具只安装缺失项。
Gradle 缓存与 Android 原生依赖缓存分别管理;原生缓存按宿主架构、构建脚本及 MiniRTC 配方区分,不复用桌面或 iOS 的配置和目标文件。GitHub 托管 runner 每次启动仍是新环境;第一次运行或缓存失效时需要安装。不同分支和标签可读取默认分支的缓存,因此建议先在默认分支运行一次 `Build Android` 预热,再创建发布标签。
@@ -151,7 +153,9 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。浮动键盘切换“电脑”后的“复制 / 粘贴 / 全选”根据远端平台选择 Ctrl 或 macOS Command。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
Windows 文本输入使用 Unicode 键盘事件,无需切换电脑输入法或覆盖剪贴板。本次开发版将完整 UTF-8 文本通过 Windows 服务转发到锁屏、登录与 UAC 安全桌面,保留数字、大小写字母、符号、空格及中文等字符,不将密码转换成数字或 ASCII 按键。该通道需要同时更新 Windows 客户端、CrossDesk Service 和会话辅助程序;仅更新 Android,或仅确认旧服务正在运行,均不会启用修复。旧版可能已声明 `supports_text_input`,但只支持普通桌面文本输入,因此“电脑键盘可输入、搜狗等系统输入法不可输入”仍可能出现。锁屏实际输入效果需在更新后的 Windows 环境验证。
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
@@ -245,4 +249,4 @@ AV1 硬件路径尚无对应实机验证;Retroid 上使用 dav1d 软件解码
设置 → 关于使用与 iOS 相同的分组及入口:版本、软件许可、开源组件、源码与构建说明。软件许可中可分别阅读 GNU GPL 第 3 版及开源软件权利说明;开源组件页单独展示 OpenFEC 告知,其余组件列出名称和许可证,详情包含版本、源码、适用的构建配方和每一份许可文档。内容按 Android 构建生成,不显示未使用的 iOS 依赖,Android 签名说明替换 Apple 签名说明。
APK 保留完整 `THIRD_PARTY_NOTICES.txt`,并生成轻量的 `ThirdPartyLicenses.json` 索引、72 份独立许可文档及 `SourceMetadata.json`。打开文档时才读取正文,长文按有限长度分块显示,支持文本选择、复制与网页链接;打包验证核对每份正文的 SHA-256。源码页面显示标签或提交版本及未发布修改提示,源码链接指向基础提交。当前 Android 构建说明、构建脚本、MiniRTC、OpenSSL 及 SVT-AV1 配方随 APK 离线提供,避免开发版链接指向基础提交中尚不存在的 Android 文件。
APK 保留完整 `THIRD_PARTY_NOTICES.txt`,并生成轻量的 `ThirdPartyLicenses.json` 索引、72 份独立许可文档及 `SourceMetadata.json`。打开文档时才读取正文,长文按有限长度分块显示,支持文本选择、复制与网页链接。构建继续生成并打包许可资料,CI 和 APK 验证不再检查许可证内容、哈希或组件清单。源码页面显示标签或提交版本及未发布修改提示,源码链接指向基础提交。当前 Android 构建说明、构建脚本、MiniRTC、OpenSSL 及 SVT-AV1 配方随 APK 离线提供,避免开发版链接指向基础提交中尚不存在的 Android 文件。
@@ -6,11 +6,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application android:theme="@style/AppTheme" android:label="CrossDesk"
<application android:name=".CrossDeskApplication" android:localeConfig="@xml/locales_config"
android:theme="@style/AppTheme" android:label="CrossDesk"
android:icon="@mipmap/ic_launcher" android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:supportsRtl="true" android:usesCleartextTraffic="false">
android:supportsRtl="false" android:usesCleartextTraffic="false">
<service android:name=".RemoteSessionService" android:exported="false"
android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
@@ -26,18 +26,18 @@ final class AboutPages {
LicenseCatalog loaded;try{loaded=new LicenseCatalog(activity);}catch(Exception error){loaded=null;}catalog=loaded;
}
void show(){
LinearLayout content=activity.formPage("about","关于 CrossDesk",settings);
LinearLayout version=activity.section(content,"");value(version,"版本",applicationVersion());
LinearLayout content=activity.formPage("about",L10n.tr("关于 CrossDesk"),settings);
LinearLayout version=activity.section(content,"");value(version,L10n.tr("版本"),applicationVersion());
updateRow=ui.row();updateRow.setPadding(ui.dp(16),0,ui.dp(16),0);updateRow.addView(activity.updateDot(),ui.size(8,8));
updateLink=ui.action("",BLUE,()->open("https://crossdesk.cn"));updateLink.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);updateLink.setPadding(ui.dp(8),ui.dp(12),0,ui.dp(12));updateRow.addView(updateLink,new LinearLayout.LayoutParams(0,-2,1));version.addView(updateRow);
updateStatus=ui.text("",13,false);updateStatus.setPadding(ui.dp(16),ui.dp(8),ui.dp(16),ui.dp(8));version.addView(updateStatus);
checkButton=ui.action("检查更新",BLUE,activity::checkAppUpdates);version.addView(checkButton);renderUpdates();ui.gap(content,28);
checkButton=ui.action(L10n.tr("检查更新"),BLUE,activity::checkAppUpdates);version.addView(checkButton);renderUpdates();ui.gap(content,28);
if(catalog==null){unavailable(content);return;}
LinearLayout links=activity.section(content,"");
activity.link(links,"软件许可","",()->component(catalog.find("crossdesk"),this::show));ui.divider(links);
activity.link(links,"开源组件","",this::components);ui.divider(links);
activity.link(links,"源码与构建说明","",this::source);
activity.footer(content,"许可与版权声明可离线查阅。");
activity.link(links,L10n.tr("软件许可"),"",()->component(catalog.find("crossdesk"),this::show));ui.divider(links);
activity.link(links,L10n.tr("开源组件"),"",this::components);ui.divider(links);
activity.link(links,L10n.tr("源码与构建说明"),"",this::source);
activity.footer(content,L10n.tr("许可与版权声明可离线查阅。"));
}
private String applicationVersion(){
return activity.applicationVersion();
@@ -45,11 +45,11 @@ final class AboutPages {
void renderUpdates(){
if(updateStatus==null)return;
updateRow.setVisibility(updates.updateAvailable()?View.VISIBLE:View.GONE);
updateLink.setText("新版本可用:v"+updates.availableVersion);
String status=!activity.hasNetworkConsent()?"完成隐私授权后可检查更新":switch(updates.status){
case CHECKING -> "正在检查更新…";
case UP_TO_DATE -> "当前已是最新版本";
case FAILED -> "检查失败,请稍后重试";
updateLink.setText(L10n.format("新版本可用:v{0}",updates.availableVersion));
String status=!activity.hasNetworkConsent()?L10n.tr("完成隐私授权后可检查更新"):switch(updates.status){
case CHECKING -> L10n.tr("正在检查更新…");
case UP_TO_DATE -> L10n.tr("当前已是最新版本");
case FAILED -> L10n.tr("检查失败,请稍后重试");
default -> "";
};
updateStatus.setText(status);updateStatus.setVisibility(status.isEmpty()?View.GONE:View.VISIBLE);
@@ -58,11 +58,11 @@ final class AboutPages {
checkButton.setAlpha(checkButton.isEnabled()?1f:.5f);
}
private void components(){
LinearLayout content=activity.formPage("open-source","开源组件",this::show);
LinearLayout notice=activity.section(content,"OpenFEC 告知");
paragraph(notice,"本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。",SECONDARY);
LinearLayout content=activity.formPage("open-source",L10n.tr("开源组件"),this::show);
LinearLayout notice=activity.section(content,L10n.tr("OpenFEC 告知"));
paragraph(notice,L10n.tr("本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。"),SECONDARY);
ui.divider(notice);paragraph(notice,"(c) Copyright 2009 - 2012 INRIA - All rights reserved",INK);ui.divider(notice);
activity.link(notice,"OpenFEC 许可与源码","",()->component(catalog.find("openfec"),this::components));ui.gap(content,28);
activity.link(notice,L10n.tr("OpenFEC 许可与源码"),"",()->component(catalog.find("openfec"),this::components));ui.gap(content,28);
LinearLayout list=activity.section(content,"");boolean first=true;
for(LicenseCatalog.Component item:catalog.components){
if(item.id.equals("crossdesk")||item.id.equals("openfec"))continue;
@@ -73,41 +73,41 @@ final class AboutPages {
row.addView(labels,new LinearLayout.LayoutParams(0,-2,1));TextView chevron=ui.text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);row.addView(chevron);
row.setContentDescription(item.name+","+item.license);row.setClickable(true);row.setFocusable(true);row.setOnClickListener(v->component(item,this::components));list.addView(row);
}
activity.footer(content,"各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。");
activity.footer(content,L10n.tr("各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。"));
}
private void component(LicenseCatalog.Component item,Runnable back){
LinearLayout content=activity.formPage("license:"+item.id,item.name,back);LinearLayout info=activity.section(content,"");
String version=item.id.equals("crossdesk")?applicationVersion():item.version.length()==40?item.version.substring(0,12):item.version;
value(info,"版本",version);ui.divider(info);value(info,"许可",item.license);ui.divider(info);
external(info,"查看源码",item.sourceURL);
value(info,L10n.tr("版本"),version);ui.divider(info);value(info,L10n.tr("许可"),item.license);ui.divider(info);
external(info,L10n.tr("查看源码"),item.sourceURL);
if(!item.buildDocuments.isEmpty()||!item.buildSourceURL.isEmpty()){
ui.divider(info);
if(!item.buildDocuments.isEmpty())activity.link(info,"构建配方与项目补丁","",()->buildSources(item,back));
else external(info,"构建配方与项目补丁",item.buildSourceURL);
if(!item.buildDocuments.isEmpty())activity.link(info,L10n.tr("构建配方与项目补丁"),"",()->buildSources(item,back));
else external(info,L10n.tr("构建配方与项目补丁"),item.buildSourceURL);
}
ui.gap(content,16);LinearLayout docs=activity.section(content,"许可与版权声明");
ui.gap(content,16);LinearLayout docs=activity.section(content,L10n.tr("许可与版权声明"));
for(int i=0;i<item.documents.size();i++){
if(i>0)ui.divider(docs);LicenseCatalog.Document document=item.documents.get(i);
activity.link(docs,item.documentTitle(document),"",()->document(item.documentTitle(document),document,()->component(item,back)));
}
}
private void source(){
LinearLayout content=activity.formPage("source","源码与构建说明",this::show);
LinearLayout info=activity.section(content,"本版本源码与构建说明");
if(catalog.source==null){paragraph(info,"本版本源码信息暂不可用。",SECONDARY);ui.divider(info);external(info,"项目主页","https://github.com/kunkundi/crossdesk");return;}
LinearLayout content=activity.formPage("source",L10n.tr("源码与构建说明"),this::show);
LinearLayout info=activity.section(content,L10n.tr("本版本源码与构建说明"));
if(catalog.source==null){paragraph(info,L10n.tr("本版本源码信息暂不可用。"),SECONDARY);ui.divider(info);external(info,L10n.tr("项目主页"),"https://github.com/kunkundi/crossdesk");return;}
String revision=catalog.source.optString("revision","");String tag=catalog.source.isNull("tag")?"":catalog.source.optString("tag","");
value(info,"源码版本",tag.isEmpty()?revision.substring(0,Math.min(12,revision.length())):tag);ui.divider(info);
if(catalog.source.optBoolean("isModified")){paragraph(info,"开发版本包含尚未发布的修改,以下链接为基础版本源码。",SECONDARY);ui.divider(info);}
external(info,"查看源码",catalog.source.optString("sourceURL",catalog.find("crossdesk").sourceURL));ui.divider(info);
value(info,L10n.tr("源码版本"),tag.isEmpty()?revision.substring(0,Math.min(12,revision.length())):tag);ui.divider(info);
if(catalog.source.optBoolean("isModified")){paragraph(info,L10n.tr("开发版本包含尚未发布的修改,以下链接为基础版本源码。"),SECONDARY);ui.divider(info);}
external(info,L10n.tr("查看源码"),catalog.source.optString("sourceURL",catalog.find("crossdesk").sourceURL));ui.divider(info);
try{
List<LicenseCatalog.Document> documents=LicenseCatalog.documents(catalog.source.optJSONArray("buildDocuments"));
if(!documents.isEmpty())actionLink(info,"构建说明",()->document("构建说明",documents.get(0),this::source));
else external(info,"构建说明",catalog.source.optString("buildInstructionsURL","https://github.com/kunkundi/crossdesk"));
}catch(org.json.JSONException error){paragraph(info,"本版本源码信息暂不可用。",SECONDARY);}
if(!documents.isEmpty())actionLink(info,L10n.tr("构建说明"),()->document(L10n.tr("构建说明"),documents.get(0),this::source));
else external(info,L10n.tr("构建说明"),catalog.source.optString("buildInstructionsURL","https://github.com/kunkundi/crossdesk"));
}catch(org.json.JSONException error){paragraph(info,L10n.tr("本版本源码信息暂不可用。"),SECONDARY);}
}
private void buildSources(LicenseCatalog.Component item,Runnable back){
LinearLayout content=activity.formPage("build:"+item.id,"构建配方与项目补丁",()->component(item,back));
if(!item.buildSourceURL.isEmpty()){LinearLayout upstream=activity.section(content,"");external(upstream,"查看源码",item.buildSourceURL);ui.gap(content,28);}
LinearLayout content=activity.formPage("build:"+item.id,L10n.tr("构建配方与项目补丁"),()->component(item,back));
if(!item.buildSourceURL.isEmpty()){LinearLayout upstream=activity.section(content,"");external(upstream,L10n.tr("查看源码"),item.buildSourceURL);ui.gap(content,28);}
LinearLayout docs=activity.section(content,"");
for(int i=0;i<item.buildDocuments.size();i++){
if(i>0)ui.divider(docs);LicenseCatalog.Document document=item.buildDocuments.get(i);
@@ -129,10 +129,10 @@ final class AboutPages {
private void actionLink(LinearLayout parent,String title,Runnable action){TextView link=ui.action(title,BLUE,action);link.setTypeface(android.graphics.Typeface.DEFAULT);link.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);link.setPadding(ui.dp(16),ui.dp(12),ui.dp(16),ui.dp(12));parent.addView(link);}
private void open(String url){
Uri uri=Uri.parse(url);if(!"https".equals(uri.getScheme())&&!"http".equals(uri.getScheme()))return;
try{activity.startActivity(new Intent(Intent.ACTION_VIEW,uri).addCategory(Intent.CATEGORY_BROWSABLE));}catch(ActivityNotFoundException error){Toast.makeText(activity,"未找到可打开链接的浏览器",Toast.LENGTH_SHORT).show();}
try{activity.startActivity(new Intent(Intent.ACTION_VIEW,uri).addCategory(Intent.CATEGORY_BROWSABLE));}catch(ActivityNotFoundException error){Toast.makeText(activity,L10n.tr("未找到可打开链接的浏览器"),Toast.LENGTH_SHORT).show();}
}
private void unavailable(LinearLayout content){
content.setGravity(Gravity.CENTER);ui.gap(content,32);TextView icon=ui.text("▤",36,false);icon.setTextColor(SECONDARY);content.addView(icon);ui.gap(content,12);
content.addView(ui.text("无法读取开源许可",17,true));TextView message=ui.text("应用中的许可文件缺失或损坏,请重新安装应用。",14,false);message.setGravity(Gravity.CENTER);message.setTextColor(SECONDARY);content.addView(message);
content.addView(ui.text(L10n.tr("无法读取开源许可"),17,true));TextView message=ui.text(L10n.tr("应用中的许可文件缺失或损坏,请重新安装应用。"),14,false);message.setGravity(Gravity.CENTER);message.setTextColor(SECONDARY);content.addView(message);
}
}
@@ -51,15 +51,15 @@ final class AnnouncementView extends ScrollView {
TextView text=ui.text(value,14,false);text.setTextColor(color);text.setLineSpacing(ui.dp(3),1);return text;
}
private void renderList(){
if(inbox.deleteFailed){content.addView(message("无法删除这条公告,请重试。",RED));ui.gap(content,12);}
if(inbox.deleteFailed){content.addView(message(L10n.tr("无法删除这条公告,请重试。"),RED));ui.gap(content,12);}
if(!inbox.isConnected()){
content.addView(message("连接服务器后可获取最新公告。",SECONDARY));
content.addView(ui.action("连接服务器",BLUE,authorize));ui.gap(content,8);
content.addView(message(L10n.tr("连接服务器后可获取最新公告。"),SECONDARY));
content.addView(ui.action(L10n.tr("连接服务器"),BLUE,authorize));ui.gap(content,8);
}else if(inbox.failed){
LinearLayout error=ui.row();error.addView(message("公告加载失败,请重试。",SECONDARY),new LinearLayout.LayoutParams(0,-2,1));
error.addView(ui.action("重试",BLUE,inbox::refresh),ui.size(64,48));content.addView(error);ui.gap(content,8);
LinearLayout error=ui.row();error.addView(message(L10n.tr("公告加载失败,请重试。"),SECONDARY),new LinearLayout.LayoutParams(0,-2,1));
error.addView(ui.action(L10n.tr("重试"),BLUE,inbox::refresh),ui.size(64,48));content.addView(error);ui.gap(content,8);
}
countLabel=ui.text("共 "+inbox.total+" 条 · "+inbox.unread+" 条未读",13,false);
countLabel=ui.text(L10n.format("共 {0} 条 · {1} 条未读",inbox.total,inbox.unread),13,false);
countLabel.setTextColor(SECONDARY);countLabel.setPadding(ui.dp(16),ui.dp(8),0,ui.dp(8));content.addView(countLabel);
LinearLayout card=ui.column();ui.card(card,12);content.addView(card);
for(AnnouncementInbox.Item item:inbox.items){
@@ -69,11 +69,11 @@ final class AnnouncementView extends ScrollView {
if(inbox.loading){
LinearLayout loading=ui.row();loading.setGravity(Gravity.CENTER);loading.setPadding(0,ui.dp(18),0,ui.dp(18));
ProgressBar spinner=new ProgressBar(getContext());spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));loading.addView(spinner,ui.size(20,20));
TextView label=message("加载中…",SECONDARY);label.setPadding(ui.dp(10),0,0,0);loading.addView(label);card.addView(loading);
TextView label=message(L10n.tr("加载中…"),SECONDARY);label.setPadding(ui.dp(10),0,0,0);loading.addView(label);card.addView(loading);
}else if(inbox.items.size()<inbox.total&&!inbox.failed){
TextView more=ui.action("加载更多",BLUE,inbox::loadMore);more.setEnabled(inbox.isConnected());card.addView(more,ui.size(-1,48));
TextView more=ui.action(L10n.tr("加载更多"),BLUE,inbox::loadMore);more.setEnabled(inbox.isConnected());card.addView(more,ui.size(-1,48));
}else if(inbox.loaded&&inbox.total==0&&!inbox.failed&&inbox.isConnected()){
TextView empty=message("暂无公告",SECONDARY);empty.setGravity(Gravity.CENTER);card.addView(empty,ui.size(-1,120));
TextView empty=message(L10n.tr("暂无公告"),SECONDARY);empty.setGravity(Gravity.CENTER);card.addView(empty,ui.size(-1,120));
}
}
private View itemRow(AnnouncementInbox.Item item){
@@ -86,23 +86,23 @@ final class AnnouncementView extends ScrollView {
words.addView(title,ui.size(-1,-2));ui.gap(words,7);TextView date=ui.text(date(item),12,false);date.setTextColor(SECONDARY);words.addView(date);
top.addView(words,new LinearLayout.LayoutParams(0,-2,1));row.addView(top,new LinearLayout.LayoutParams(0,-2,1));
TextView chevron=ui.text("›",23,false);chevron.setTextColor(0xFFB7B7BD);chevron.setPadding(ui.dp(10),0,0,0);row.addView(chevron);
row.setContentDescription(item.title+","+date(item)+","+(item.read?"已读":"未读"));row.setFocusable(true);
row.setContentDescription(item.title+","+date(item)+","+(item.read?L10n.tr("已读"):L10n.tr("未读")));row.setFocusable(true);
row.setOnClickListener(v->open.accept(item));row.setOnLongClickListener(v->{confirmDelete(item);return true;});
return new SwipeRow(row,()->confirmDelete(item));
}
private void confirmDelete(AnnouncementInbox.Item item){
if(deleteDialog!=null)deleteDialog.dismiss();
deleteDialog=new AlertDialog.Builder(getContext()).setTitle("删除公告").setMessage("删除这条公告?")
.setNegativeButton("取消",null).setPositiveButton("删除",(dialog,which)->inbox.dismiss(item)).show();
deleteDialog=new AlertDialog.Builder(getContext()).setTitle(L10n.tr("删除公告")).setMessage(L10n.tr("删除这条公告?"))
.setNegativeButton(L10n.tr("取消"),null).setPositiveButton(L10n.tr("删除"),(dialog,which)->inbox.dismiss(item)).show();
}
private void renderDetail(){
AnnouncementInbox.Item item=inbox.find(id,revision);
if(item==null){content.addView(message("这条公告已更新或删除,请返回公告列表查看。",SECONDARY));return;}
if(item==null){content.addView(message(L10n.tr("这条公告已更新或删除,请返回公告列表查看。"),SECONDARY));return;}
content.addView(ui.text(item.title,22,true));ui.gap(content,16);
TextView date=ui.text(date(item)+" · "+(item.read?"已读":"未读"),12,false);date.setTextColor(SECONDARY);content.addView(date);ui.gap(content,16);
TextView date=ui.text(date(item)+" · "+(item.read?L10n.tr("已读"):L10n.tr("未读")),12,false);date.setTextColor(SECONDARY);content.addView(date);ui.gap(content,16);
if(inbox.readSaveFailed){
content.addView(message("无法保存本地阅读状态,请重试。",RED));
content.addView(ui.action("重试保存阅读状态",BLUE,()->inbox.markRead(item)));ui.gap(content,16);
content.addView(message(L10n.tr("无法保存本地阅读状态,请重试。"),RED));
content.addView(ui.action(L10n.tr("重试保存阅读状态"),BLUE,()->inbox.markRead(item)));ui.gap(content,16);
}
TextView body=ui.text("",17,false);body.setText(AnnouncementText.format(item.body));body.setTextIsSelectable(true);
body.setMovementMethod(LinkMovementMethod.getInstance());body.setLinksClickable(true);body.setLinkTextColor(BLUE);body.setLineSpacing(ui.dp(4),1);
@@ -127,7 +127,7 @@ final class AnnouncementView extends ScrollView {
trackPull(event);
if(!pulling)return super.onTouchEvent(event);
boolean reached=event.getY()-downY>=ui.dp(72);
if(event.getActionMasked()==MotionEvent.ACTION_MOVE&&countLabel!=null)countLabel.setText(reached?"松开刷新":"下拉刷新");
if(event.getActionMasked()==MotionEvent.ACTION_MOVE&&countLabel!=null)countLabel.setText(reached?L10n.tr("松开刷新"):L10n.tr("下拉刷新"));
if(event.getActionMasked()==MotionEvent.ACTION_UP||event.getActionMasked()==MotionEvent.ACTION_CANCEL){
pulling=false;if(reached&&event.getActionMasked()==MotionEvent.ACTION_UP)inbox.refresh();else render();
}
@@ -142,7 +142,7 @@ final class AnnouncementView extends ScrollView {
private boolean dragging;
SwipeRow(View front,Runnable delete){
super(ui.context);this.front=front;setClipChildren(true);
remove=ui.action("删除",Color.WHITE,delete);remove.setBackgroundColor(RED);
remove=ui.action(L10n.tr("删除"),Color.WHITE,delete);remove.setBackgroundColor(RED);
remove.setVisibility(INVISIBLE);
remove.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);remove.setFocusable(false);
addView(remove,new FrameLayout.LayoutParams(ui.dp(76),-1,Gravity.END));addView(front,new FrameLayout.LayoutParams(-1,-2));
@@ -19,6 +19,7 @@ final class BackgroundSession implements NativeSession.Listener {
RemoteVideoSettings videoSettings;
int signalState, selectedDisplay, width, height;
boolean muted, remoteServiceAvailable, background, ended;
boolean autoLandscape;
File receivedFile;
BackgroundSession(Context context, NativeSession session, String host, int port, String remote) {
@@ -45,7 +46,7 @@ final class BackgroundSession implements NativeSession.Listener {
if (listener != null) listener.ended(reason);
}
@Override public void ended(String reason) { finish(reason); }
@Override public void passwordRejected() { finish("远端访问密码已失效,请重新连接"); }
@Override public void passwordRejected() { finish(L10n.tr("远端访问密码已失效,请重新连接")); }
@Override public void connected() { /* Already connected before the service takes ownership. */ }
@Override public void status(String message) { if (view != null) view.status(message); }
@Override public void signaling(int state, String id) {
@@ -74,11 +75,12 @@ final class BackgroundSession implements NativeSession.Listener {
@Override public void statistics(RemoteNetworkStatistics.Snapshot value) { if (view != null) view.statistics(value); }
@Override public void fileTransfer(String name, double progress, boolean sending, File received) {
if (received != null) receivedFile = received;
fileStatus = progress < 0 ? name + " 传输失败" : (sending ? "发送 " : "接收 ") + name
+ (progress >= 1 ? " · 已完成" : " · " + (int)(progress * 100) + "%");
fileStatus = progress < 0 ? L10n.format("{0} 传输失败", name) : (sending ? L10n.tr("发送 ") : L10n.tr("接收 ")) + name
+ (progress >= 1 ? L10n.tr(" · 已完成") : " · " + (int)(progress * 100) + "%");
if (view != null) view.fileTransfer(name, progress, sending, received);
}
@Override public void controlFeedback(String text) { if (view != null) view.controlFeedback(text); }
@Override public void privacyChanged() { if (view != null) view.privacyChanged(); }
@Override public void presence(JSONObject message) { if (view != null) view.presence(message); }
@Override public void announcement(JSONObject message) { if (view != null) view.announcement(message); }
@Override public void announcementFailed(String id) { if (view != null) view.announcementFailed(id); }
@@ -0,0 +1,11 @@
package cn.crossdesk.mobile;
import android.app.Application;
/** Initialize localization before any activity, background service or model. */
public final class CrossDeskApplication extends Application {
@Override public void onCreate() {
super.onCreate();
L10n.initialize(this);
}
}
@@ -0,0 +1,33 @@
package cn.crossdesk.mobile;
import android.content.Context;
import java.util.Locale;
/** Native resource lookup; never translates remote content, file names or protocol data. */
final class L10n {
private static volatile Context application;
static void initialize(Context context) { application = context.getApplicationContext(); }
static String tr(String key) {
Context context = application;
Integer id = LocalizationKeys.IDS.get(key);
return context == null || id == null ? key : context.getString(id);
}
static String format(String key, Object... args) {
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\{(\\d+)\\}").matcher(tr(key));
StringBuffer result = new StringBuffer();
while (matcher.find()) {
int index = Integer.parseInt(matcher.group(1));
String value = index < args.length ? String.valueOf(args[index]) : matcher.group();
matcher.appendReplacement(result, java.util.regex.Matcher.quoteReplacement(value));
}
matcher.appendTail(result);
return result.toString();
}
static boolean isChinese() {
Context context = application;
Locale locale = context == null ? Locale.getDefault()
: context.getResources().getConfiguration().getLocales().get(0);
return locale.getLanguage().equals("zh");
}
private L10n() {}
}
@@ -28,9 +28,9 @@ final class LicenseCatalog {
if(documents.isEmpty())throw new JSONException("Missing documents");
}
String documentTitle(Document document){
if(document.title.equals("Source copyright and license notices"))return "版权与许可声明";
if(document.title.equals("apps/android/licenses/OPEN_SOURCE_NOTICE.txt"))return "开源软件权利说明";
if(id.equals("crossdesk")&&document.title.equals("LICENSE"))return "GNU GPL 第 3 版";
if(document.title.equals("Source copyright and license notices"))return L10n.tr("版权与许可声明");
if(document.title.equals("apps/android/licenses/OPEN_SOURCE_NOTICE.txt"))return L10n.tr("开源软件权利说明");
if(id.equals("crossdesk")&&document.title.equals("LICENSE"))return L10n.tr("GNU GPL 第 3 版");
return document.title;
}
}
@@ -0,0 +1,316 @@
package cn.crossdesk.mobile;
import java.util.HashMap;
import java.util.Map;
/** Generated resource keys; text and locale selection live in Android resources. */
final class LocalizationKeys {
static final Map<String, Integer> IDS = new HashMap<>();
static {
IDS.put(" · 已完成", R.string.text_58b6df1059088699);
IDS.put(" 传输失败", R.string.text_53af552fb3af7f6e);
IDS.put(" 台设备", R.string.text_23ebae95d26dfe5e);
IDS.put(" 条 · ", R.string.text_a7f4dfd234a43b16);
IDS.put(" 条未读", R.string.text_2c894057fc526576);
IDS.put("CrossDesk 远程连接", R.string.text_d0ac753c6681e578);
IDS.put("GNU GPL 第 3 版", R.string.text_0991c6fc1de4ffd6);
IDS.put("OpenFEC 告知", R.string.text_5ae7dc4079223967);
IDS.put("OpenFEC 许可与源码", R.string.text_69ad29820a259526);
IDS.put("P2P 直连", R.string.text_63ae4a0c7a448d21);
IDS.put("SRTP 已启用", R.string.text_70cae3c6aad8e152);
IDS.put("TURN 中继", R.string.text_ef813fec7d6d8a3b);
IDS.put("{0} 传输失败", R.string.text_ebede975edd52aee);
IDS.put("{0} 台设备", R.string.text_b3ffb6e982ecf5df);
IDS.put("{0} 条未读", R.string.text_fc66a78b9e27fecf);
IDS.put("{0},接收 {1},发送 {2},丢包率 {3}", R.string.text_dcb59cdcf66150a1);
IDS.put("● 已连接", R.string.text_01965b3249458d01);
IDS.put("下拉刷新", R.string.text_eb4236fe6a926a5a);
IDS.put("丢包率", R.string.text_37772187ab854191);
IDS.put("中", R.string.text_a567bdaa11367f26);
IDS.put("优先保持画面流畅,带宽或编码压力较大时会更积极地降低分辨率。", R.string.text_e6ff95a9fe6acc7f);
IDS.put("低", R.string.text_aa9e366f68d3d097);
IDS.put("使用自定义服务器", R.string.text_f03d03eb35e3330c);
IDS.put("保存密码", R.string.text_2e9f4819e064ce12);
IDS.put("保存收到的文件", R.string.text_10019cef593819ed);
IDS.put("保存远程画面预览", R.string.text_3bcdcd330bb3044c);
IDS.put("保存远程画面预览?", R.string.text_0f197074373e38fd);
IDS.put("信令连接中断,请重新连接", R.string.text_71ca53f95a8484da);
IDS.put("公告加载失败,请重试。", R.string.text_789f22a8b1b34797);
IDS.put("公告详情", R.string.text_00554b17c432937e);
IDS.put("共 ", R.string.text_2428420e407fe2e8);
IDS.put("共 {0} 条 · {1} 条未读", R.string.text_d977c7568cc5201e);
IDS.put("关于", R.string.text_52d25a9e30ba94f1);
IDS.put("关于 CrossDesk", R.string.text_459c1e980c1c8ce2);
IDS.put("关于,有新版本", R.string.text_390a95b65eb33052);
IDS.put("关闭", R.string.text_3fd47edce45b3603);
IDS.put("关闭控制栏", R.string.text_56a18c93e12fdc4e);
IDS.put("关闭隐私屏", R.string.text_4eb0565cc4658d7f);
IDS.put("兼顾清晰度和流畅度,只在持续压力下逐步降低分辨率。", R.string.text_7d466b69be42fc43);
IDS.put("分辨率", R.string.text_b92421822c213c2b);
IDS.put("删除", R.string.text_2f9daa828907b93f);
IDS.put("删除公告", R.string.text_0e46cdb3442edfba);
IDS.put("删除记录", R.string.text_f2cf9101bb2d8816);
IDS.put("删除这条公告?", R.string.text_d087671ca7df7f0f);
IDS.put("刷新公告", R.string.text_33e7584cd5c252d7);
IDS.put("剪贴板", R.string.text_8c10e2c1728ddf8e);
IDS.put("加载中…", R.string.text_4927a53bcc886afb);
IDS.put("加载更多", R.string.text_af90a08fec8ee28d);
IDS.put("升级提示", R.string.text_a739f24a9fd8a5d9);
IDS.put("原生连接初始化失败", R.string.text_861521c9e107c648);
IDS.put("发送", R.string.text_edecf0ae6e5144f9);
IDS.put("发送 ", R.string.text_43b7b60867ca51e7);
IDS.put("发送后,在远程键盘中点击“粘贴”。接收的远端文本可手动复制到本机。", R.string.text_da7df5a5543c4ba1);
IDS.put("发送文件", R.string.text_e7281fbca7af369c);
IDS.put("发送文本", R.string.text_ce6c02d19022e016);
IDS.put("取消", R.string.text_2cd0f3be8738a86c);
IDS.put("取消连接", R.string.text_3687f953cfe85fb8);
IDS.put("各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。", R.string.text_72814b15a6fa152c);
IDS.put("合计", R.string.text_e009c148d1890dac);
IDS.put("同意并继续", R.string.text_81c92013219dd115);
IDS.put("后台保持连接,点按返回远程桌面", R.string.text_409696774c9998ce);
IDS.put("后台保活未能启动,请保持应用在前台", R.string.text_dcb8d4e2e6973912);
IDS.put("后台保活未能启动,请保持应用在前台后重新连接", R.string.text_d96a21463557171d);
IDS.put("后台连接已结束,请重新连接", R.string.text_952f7300de0a7bd9);
IDS.put("后台连接服务已停止,请重新连接", R.string.text_da904f715e1cff87);
IDS.put("后续连接仍会保存新的预览图。", R.string.text_0812359e18082071);
IDS.put("在线", R.string.text_b9086662b1df1f20);
IDS.put("填写主机名或 IPv4 地址,不含协议和路径", R.string.text_39f2f444a042643e);
IDS.put("声音", R.string.text_473400815c0f687d);
IDS.put("复制远端文本", R.string.text_ab5ca1fab212f4ca);
IDS.put("始终保持较高分辨率,带宽不足时通过降低帧率来保证画面清晰度。", R.string.text_c92269b2039e7aa3);
IDS.put("媒体加密", R.string.text_7ff73de85ec16d15);
IDS.put("字母数字", R.string.text_22dc6217af77bc37);
IDS.put("安全存储或证书初始化失败,请重试或清除应用数据", R.string.text_9ca1cdaf831182e3);
IDS.put("完成", R.string.text_c0b3fbff51ccc40b);
IDS.put("完成隐私授权后可检查更新", R.string.text_678328f6f02881c7);
IDS.put("完成隐私授权后登记本机身份", R.string.text_c4361f9abdba4882);
IDS.put("密码保存失败,本次连接仍可使用", R.string.text_c29b888e357d727d);
IDS.put("密码将加密保存在本机 Android Keystore 中", R.string.text_3f31300a23afb390);
IDS.put("对端 ID", R.string.text_8d3b052c4c67acda);
IDS.put("对端 ID ", R.string.text_0512084e130984bc);
IDS.put("对端 ID {0}", R.string.text_c5147d65960196b0);
IDS.put("将删除本机保存的所有远程预览图,保留连接记录和密码。", R.string.text_882b705d31ac7e2a);
IDS.put("屏幕方向", R.string.text_60cc99cc0013972e);
IDS.put("展开远程控制菜单", R.string.text_9e7294d264afecb7);
IDS.put("已从通知断开远程连接", R.string.text_9343612ee473edd6);
IDS.put("已保存密码", R.string.text_9d32476fe83aef60);
IDS.put("已保存密码无法读取,请重新输入", R.string.text_1cbd391383283924);
IDS.put("已保存的密码未能保留,请重新输入一次", R.string.text_6792b9f341380c68);
IDS.put("已发送 Ctrl+Alt+Del", R.string.text_74d3f0927d465047);
IDS.put("已启用后台保活,切换应用可继续保持连接", R.string.text_c28d86acbedba3b6);
IDS.put("已启用后台保活,可从连接通知返回或断开", R.string.text_506409ec08cb2a79);
IDS.put("已授权", R.string.text_521e65ffc7d0ff2d);
IDS.put("已读", R.string.text_efdd1a47e4beb2f9);
IDS.put("已连接 ", R.string.text_1f672b9b18d18e87);
IDS.put("已连接服务器", R.string.text_9c05d9d7fa1b0abd);
IDS.put("帧率", R.string.text_f9404bf335bc14cf);
IDS.put("帧率优先", R.string.text_9ae0c69e26db1d6a);
IDS.put("平衡", R.string.text_df544e46aa4d5c17);
IDS.put("应用中的许可文件缺失或损坏,请重新安装应用。", R.string.text_3362e8fa7f65cc2f);
IDS.put("开发版本包含尚未发布的修改,以下链接为基础版本源码。", R.string.text_3f2904435942cc0f);
IDS.put("开启保存", R.string.text_4338afaad52ea445);
IDS.put("开启隐私屏", R.string.text_c8bb8255390df3b6);
IDS.put("开源组件", R.string.text_c6ecaca362ae1c80);
IDS.put("开源软件权利说明", R.string.text_6099db946bdb5ee4);
IDS.put("当前已是最新版本", R.string.text_6e86570183c3cdd0);
IDS.put("当前远端不支持直接输入此文本,请使用支持文本输入的被控端版本,或通过剪贴板发送", R.string.text_24a52788cf474bf6);
IDS.put("成功连接后,这里会显示最近的远程设备。", R.string.text_33f25b500697a7d9);
IDS.put("我已阅读并同意《隐私政策》", R.string.text_70341256d7317e7d);
IDS.put("所选文件(无法读取或空间不足)", R.string.text_85c4c8fda0461f35);
IDS.put("找不到该设备 ID", R.string.text_9b58c9ff5f6a856f);
IDS.put("指令发送失败,请重试", R.string.text_97b7b18df07b9787);
IDS.put("指令已发送,等待电脑确认…", R.string.text_cb782027ca5986c7);
IDS.put("授权状态", R.string.text_3ca40a586103f6b7);
IDS.put("接收", R.string.text_6e684586884ebee6);
IDS.put("接收 ", R.string.text_84ae92e54e3078aa);
IDS.put("控制端解码器", R.string.text_d16488b987723161);
IDS.put("撤回", R.string.text_6b58198955259066);
IDS.put("撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。", R.string.text_c52e42ee856825db);
IDS.put("撤回联网授权", R.string.text_61f4788032ec596b);
IDS.put("撤回联网授权?", R.string.text_e736e419008ac10a);
IDS.put("收起远程控制菜单", R.string.text_73431dcf0afb7a65);
IDS.put("收起键盘", R.string.text_d826c4ece4dd4031);
IDS.put("收起鼠标", R.string.text_6e9a457c0c3c34ed);
IDS.put("数据", R.string.text_5440f7424f2865bf);
IDS.put("文件保存失败,请重试", R.string.text_94244f41d0a41378);
IDS.put("文件已保存", R.string.text_2553a785fef2a405);
IDS.put("文件选择已超时,会话已结束", R.string.text_d573b3f9a810ea04);
IDS.put("文本超过 128 KB", R.string.text_985a5c380be49f4f);
IDS.put("断开", R.string.text_f33ac04eece6a6a6);
IDS.put("断开后将返回首页", R.string.text_09b1395f50589327);
IDS.put("断开远程连接?", R.string.text_a73c0680db0d2e06);
IDS.put("断开连接", R.string.text_a33f3804768e68ee);
IDS.put("新版本", R.string.text_47ceaf502c5a05d6);
IDS.put("新版本可用:v", R.string.text_78823e6cf1a8afe5);
IDS.put("新版本可用:v{0}", R.string.text_b7e58b67fa8f7e78);
IDS.put("无未读公告", R.string.text_ef23787ec32c5588);
IDS.put("无法保存本地阅读状态,请重试。", R.string.text_dca2c418ac967366);
IDS.put("无法删除这条公告,请重试。", R.string.text_5c92da76360056cc);
IDS.put("无法发起远程连接", R.string.text_615709dd3129313c);
IDS.put("无法读取开源许可", R.string.text_a5c4f4ad79a640ae);
IDS.put("显示器", R.string.text_1a13ea5910a0c5e6);
IDS.put("显示密码", R.string.text_ffe21a671190323e);
IDS.put("暂不同意", R.string.text_a94460e8a04f04c5);
IDS.put("暂无公告", R.string.text_6d9a7758610a2432);
IDS.put("最近连接", R.string.text_0b290c6600d92f43);
IDS.put("服务器", R.string.text_2388cbc6a486af9f);
IDS.put("服务器 TLS 证书校验失败", R.string.text_dfe408e686175095);
IDS.put("服务器地址", R.string.text_d3716cc5a2f5a810);
IDS.put("服务器登录失败,请检查服务器地址和网络", R.string.text_2a9608d4465d0f70);
IDS.put("服务器端口", R.string.text_d270868960e68629);
IDS.put("服务器连接超时,请检查服务器地址和网络", R.string.text_7a4e4a6a1b479a52);
IDS.put("未启用", R.string.text_f95ea7f4c063eea7);
IDS.put("未找到可打开链接的浏览器", R.string.text_c8f7ed6f2186fb98);
IDS.put("未授权", R.string.text_94bc3d40defef3e9);
IDS.put("未读", R.string.text_4d0680f9efaef147);
IDS.put("未连接服务器 · 点击重试", R.string.text_d1fcb66a323a16f5);
IDS.put("本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。", R.string.text_7eea7966c0b746c5);
IDS.put("本机 ID ", R.string.text_c611b4e50b0dbc08);
IDS.put("本机 ID {0}", R.string.text_97d10220f08dd7fc);
IDS.put("本版本源码与构建说明", R.string.text_91f15d505770d7a0);
IDS.put("本版本源码信息暂不可用。", R.string.text_5b2c8ca2c5a87241);
IDS.put("松开刷新", R.string.text_f0e274aa130e08a2);
IDS.put("构建说明", R.string.text_47077144715b7074);
IDS.put("构建配方与项目补丁", R.string.text_837ab2d0b2f4cb98);
IDS.put("查看源码", R.string.text_921c3b62f1f7d6f4);
IDS.put("检查失败,请稍后重试", R.string.text_fdd1059f10c4b979);
IDS.put("检查更新", R.string.text_7f68ebad19ba6bcd);
IDS.put("正在关闭隐私屏…", R.string.text_4f4d1e704e38c161);
IDS.put("正在建立安全连接…", R.string.text_2a05f6fbd3f4436e);
IDS.put("正在开启隐私屏…", R.string.text_0da575e3c92de73a);
IDS.put("正在检查更新…", R.string.text_91834aebecabcbc3);
IDS.put("正在清除…", R.string.text_0571a5f5f8ec4c83);
IDS.put("正在获取本机 ID…", R.string.text_0e6336c8fe918730);
IDS.put("正在读取所选文件…", R.string.text_faebd20c2801fd0f);
IDS.put("正在连接", R.string.text_694186911ae3e4da);
IDS.put("正在连接信令服务器…", R.string.text_c3ccc7f7d37533e3);
IDS.put("正在连接服务器…", R.string.text_4024d3c0f467a4f6);
IDS.put("正在重连服务器…", R.string.text_46fe1ef52ec3a10f);
IDS.put("此设备没有可用的文件选择器", R.string.text_4e4fce649fc00238);
IDS.put("清除", R.string.text_bce2377283c2455a);
IDS.put("清除预览图", R.string.text_ee0e76b8507212de);
IDS.put("清除预览图?", R.string.text_ecc809e44637de18);
IDS.put("源码与构建说明", R.string.text_2782e4485ad23d34);
IDS.put("源码版本", R.string.text_1f566fe8f8b718f3);
IDS.put("版本", R.string.text_5f76b2bf82dd2c5d);
IDS.put("版权与许可声明", R.string.text_16410ee9663fad10);
IDS.put("电脑键盘", R.string.text_11de77e1e41e680f);
IDS.put("画质优先", R.string.text_20f57900234debd7);
IDS.put("画面偏好", R.string.text_43b04aba53a54cf5);
IDS.put("画面延时", R.string.text_82a5600c470179fa);
IDS.put("画面设置", R.string.text_33b458faaf967e15);
IDS.put("画面质量", R.string.text_afb5acdf8f07fadb);
IDS.put("画面采集帧率", R.string.text_12b41b7a3fbcf8ca);
IDS.put("相对位置", R.string.text_66062971234f422d);
IDS.put("相对鼠标", R.string.text_c421dc051bea3f11);
IDS.put("知道了", R.string.text_de32e20193ad1042);
IDS.put("确定", R.string.text_fac2a67ad87807c4);
IDS.put("确认", R.string.text_36f33adaf0942634);
IDS.put("确认连接", R.string.text_a5b7fba4dbd8f622);
IDS.put("离线", R.string.text_be1b4f3c6c1cda1e);
IDS.put("空格", R.string.text_bfa4a6e0fe42ac28);
IDS.put("端口范围为 1–65535", R.string.text_471a579e8d87faac);
IDS.put("符号功能", R.string.text_c7d0c77862e32b52);
IDS.put("等待电脑返回隐私屏状态;旧版电脑端可能不支持", R.string.text_a562f07bd38c1a49);
IDS.put("等待远端屏幕信息", R.string.text_5ee880e2524a983a);
IDS.put("等待远端画面…", R.string.text_674c723a85ee7f57);
IDS.put("等待隐私授权", R.string.text_119f0483da4756c4);
IDS.put("系统键盘", R.string.text_7916071c1a8ee458);
IDS.put("绝对位置", R.string.text_02e9ba6fda719e2e);
IDS.put("绝对鼠标", R.string.text_8527c8c6d1ccc1ca);
IDS.put("网络状态", R.string.text_872428a7210d5e0c);
IDS.put("自定义", R.string.text_4eafa9e925b30bcd);
IDS.put("虚拟鼠标", R.string.text_dffd0c12b8f27247);
IDS.put("被控端已通过组合键解除隐私屏。点击“锁定”将重新开启隐私屏,并禁止本机使用组合键解锁。", R.string.text_62c199f37bf95869);
IDS.put("被控端版本过低,请升级到最新版本。", R.string.text_a569ce729c1d6e19);
IDS.put("被控端编码器", R.string.text_d814476826827b78);
IDS.put("要发送到远端的文本", R.string.text_488402883e27f6f0);
IDS.put("视频", R.string.text_c20f7618d330a854);
IDS.put("许可", R.string.text_310c625425f014d8);
IDS.put("许可与版权声明", R.string.text_5c34f7d0e2124dca);
IDS.put("许可与版权声明可离线查阅。", R.string.text_e24134b643a17975);
IDS.put("设备身份保存失败,请检查安全存储", R.string.text_5e7628f043ce71d0);
IDS.put("设备身份及选择保存的访问密码使用 Android Keystore 加密。连接记录仅保存在本机,不纳入设备备份。剪贴板内容在结束会话时清除。离开应用后通过画中画或连接通知保持会话,可在应用或通知中断开。", R.string.text_5d35ee0d7945fcbc);
IDS.put("设置", R.string.text_df3d58c7d84b85f2);
IDS.put("设置,有新版本", R.string.text_4f8771952222a011);
IDS.put("访问密码", R.string.text_f4f712d5be91721a);
IDS.put("访问密码错误,请重新输入", R.string.text_dd6c465847af1b60);
IDS.put("证书校验失败 · 点击重试", R.string.text_defba1d2989bfab4);
IDS.put("请先完成联网授权", R.string.text_44f3d4d52347095a);
IDS.put("请在远端启用 CrossDesk 系统服务后重试", R.string.text_2a07ca002bc5093d);
IDS.put("请求超时,尚未确认电脑的隐私屏状态", R.string.text_990fd23c62de5ccf);
IDS.put("请等待当前文件发送完成", R.string.text_e5c8fa6dcf7b2a8b);
IDS.put("请输入 1–65535 范围内的端口", R.string.text_9305d9d72b06110d);
IDS.put("请输入 9 位设备 ID", R.string.text_3c7223dfb8dde5cf);
IDS.put("请输入有效访问密码", R.string.text_285a0714894d2899);
IDS.put("调整失败,请重试。", R.string.text_c808bb77ffd2b4cd);
IDS.put("软件许可", R.string.text_a762e7e650e285fa);
IDS.put("返回", R.string.text_572cf45ba43634b3);
IDS.put("返回控制栏", R.string.text_7565f94031582c7c);
IDS.put("还没有连接记录", R.string.text_55113b6925c362fd);
IDS.put("这条公告已更新或删除,请返回公告列表查看。", R.string.text_3aa527b2106cc987);
IDS.put("远程任务已关闭", R.string.text_e357aa9a0fb28c16);
IDS.put("远程桌面", R.string.text_c255841f68096d3b);
IDS.put("远程桌面触控区域", R.string.text_133f4c7dabd88b97);
IDS.put("远程桌面:轻触点击,拖动移动,长按右键,双指捏合缩放,放大后双指拖动画面,原始比例下双指上下滑动滚动", R.string.text_5931aeb3778f28f1);
IDS.put("远程电脑", R.string.text_3535087994d050de);
IDS.put("远程窗口已关闭,或后台保活未能启动,会话已结束", R.string.text_ed2e03072ce131de);
IDS.put("远程连接", R.string.text_678b783fb578172b);
IDS.put("远端密码错误", R.string.text_deccbd3eb9ba45f2);
IDS.put("远端已关闭连接", R.string.text_a2ce499295e358ae);
IDS.put("远端当前不可用", R.string.text_176e117df3ace734);
IDS.put("远端访问密码已失效,请重新连接", R.string.text_d2c0694243f93249);
IDS.put("连接", R.string.text_a5574109f0208e89);
IDS.put("连接 ", R.string.text_1d54bfbae5f8ac08);
IDS.put("连接失败", R.string.text_89c4766afc85125f);
IDS.put("连接失败 · 点击重试", R.string.text_04bf1a61ba1369f6);
IDS.put("连接已断开", R.string.text_2ab7d89557e779ab);
IDS.put("连接已结束", R.string.text_c1d20bf4c3e471c1);
IDS.put("连接延时(RTT)", R.string.text_e57d2cbc0e9908e6);
IDS.put("连接建立后可调整,需被控端支持。", R.string.text_db4d4f0fab78498a);
IDS.put("连接建立后锁定横屏", R.string.text_2123a9ed718f2c40);
IDS.put("连接提示", R.string.text_f741e0536210eafa);
IDS.put("连接方式", R.string.text_485a26050ce57431);
IDS.put("连接服务器", R.string.text_5a048351a77fe88d);
IDS.put("连接服务器后可获取最新公告。", R.string.text_5854c4add7ab0f7a);
IDS.put("连接超时,请检查设备 ID、服务器及远端在线状态", R.string.text_32cd27b5a72515f3);
IDS.put("连接远程桌面", R.string.text_6edc4b54cc1a9838);
IDS.put("退格", R.string.text_d5407b4f3c666681);
IDS.put("通知公告", R.string.text_e02926784fcedb91);
IDS.put("部分预览图未能清除,请重试。", R.string.text_465a092680d132b3);
IDS.put("重试", R.string.text_b8784c8dd5636ff2);
IDS.put("重试保存阅读状态", R.string.text_a504fb6f162bdb20);
IDS.put("锁定", R.string.text_ff9872a023e3a8aa);
IDS.put("键盘", R.string.text_864200bbb241e067);
IDS.put("阅读并授权", R.string.text_1f0f014d4177a0cc);
IDS.put("隐私", R.string.text_86651d17a401c55b);
IDS.put("隐私与授权", R.string.text_26c6e011e343708c);
IDS.put("隐私屏", R.string.text_6cad42fd3da85585);
IDS.put("隐私屏已关闭", R.string.text_e157fafb76aad954);
IDS.put("隐私屏已开启", R.string.text_bbef0eac0c04f214);
IDS.put("隐私屏已被解锁", R.string.text_0c7535f7c5e43b6f);
IDS.put("隐私屏异常", R.string.text_b62ac3dfb606c00b);
IDS.put("隐私屏暂不可用", R.string.text_598a2747dbd21dd5);
IDS.put("隐私屏状态已过期,等待电脑确认", R.string.text_4d59be3c75189032);
IDS.put("隐私屏状态待确认", R.string.text_1674532c70ed1db2);
IDS.put("隐私政策", R.string.text_e464b41fd5c45ba5);
IDS.put("隐私政策暂时无法读取,请拒绝联网并重新安装应用。", R.string.text_554e957e8e3c00af);
IDS.put("静音", R.string.text_7db2e189ecb01065);
IDS.put("音频", R.string.text_296c632ec857a0ba);
IDS.put("项目主页", R.string.text_0cadb49096a382e0);
IDS.put("预览可能包含远程电脑上的个人或工作信息。开启后,每次连接会自动将一张画面保存在本机并显示在最近连接中。你可以随时关闭并清除。", R.string.text_3beca60bd0c654f7);
IDS.put("高", R.string.text_b1c27820fec23edb);
IDS.put("默认", R.string.text_844b8cc8dff7c1d8);
IDS.put("默认关闭。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受影响。", R.string.text_4db8737054f3b0cc);
IDS.put("鼠标右键,按住并拖动,松开结束拖拽", R.string.text_1184a97edd8e1646);
IDS.put("鼠标左键,按住并拖动,松开结束拖拽", R.string.text_926eaa3af0b5e48e);
IDS.put("鼠标控制", R.string.text_269f39a07b4a4e01);
IDS.put("鼠标滚轮,上下滑动,可滑出按键范围", R.string.text_cba62aeeb4a63a23);
IDS.put(",丢包率 ", R.string.text_9aa238fc6121dede);
IDS.put(",发送 ", R.string.text_0b5d16c21095fe10);
IDS.put(",已保存密码", R.string.text_ba9a792d063a8c86);
IDS.put(",接收 ", R.string.text_f4c1f9d7896760a7);
IDS.put(",点击重试", R.string.text_42237b5ec70c26f3);
}
private LocalizationKeys() {}
}
@@ -72,12 +72,14 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private int videoWidth,videoHeight;
private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture();
private RemoteVideoView video;
private RemoteOrientation remoteOrientation;
private SessionControls controls;
private Dialog passwordDialog, progressDialog, privacyDialog;
private AlertDialog disconnectDialog;
private AlertDialog connectionDialog;
private String pendingConnectionMessage="";
private AlertDialog remoteUpdateDialog;
private AlertDialog privacyUnlockDialog;
private RemoteVersionCheck remoteVersionCheck;
private String host, connectedRemote="", hostPlatform="", remoteClipboard="", pendingPassword="", page="home";
private int port,selectedDisplay;
@@ -87,7 +89,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private static final int PICK_FILE=41,SAVE_FILE=42;
private NativeSession documentSession;
private java.io.File receivedFile,documentToSave;
private final Runnable documentTimeout=()->{if(documentSession!=null&&!foreground)end("文件选择已超时,会话已结束");};
private final Runnable documentTimeout=()->{if(documentSession!=null&&!foreground)end(L10n.tr("文件选择已超时,会话已结束"));};
private RemoteVideoSettings videoSettings=new RemoteVideoSettings(1);
private final android.os.Handler mainHandler=new android.os.Handler(android.os.Looper.getMainLooper());
private Runnable videoSettingsTimeout;
@@ -95,7 +97,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private Runnable backAction;
@Override public void onCreate(Bundle state){
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);remoteOrientation=new RemoteOrientation(this);
pageHost=new PageHost(this);setContentView(pageHost);
preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099);
recentPresence.sender=ids->{if(signaling!=null&&!signaling.isClosed())signaling.presenceRequest(ids);};
@@ -124,6 +126,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
else motion=navigatingBack||nextPage.equals("home")?PageHost.Motion.POP:PageHost.Motion.PUSH;
}
if(root!=null)hideKeyboard();page=nextPage;
// Remote controls own the IME inset. Android 11–14 otherwise also resize
// the content, unlike Android 15's enforced edge-to-edge layout.
if(android.os.Build.VERSION.SDK_INT>=30&&android.os.Build.VERSION.SDK_INT<35)getWindow().setDecorFitsSystemWindows(!dark);
signalBadge=null;identitySummary=null;announcementBadge=null;announcementView=null;announcementRefresh=null;
settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null;
previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null;
@@ -132,7 +137,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720);
safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL));
safeArea.setOnApplyWindowInsetsListener((view,insets)->{
view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;
if(dark&&android.os.Build.VERSION.SDK_INT>=30){
android.graphics.Insets bars=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout());
view.setPadding(bars.left,bars.top,bars.right,bars.bottom);
if(controls!=null)controls.keyboardInsets(Math.max(0,insets.getInsets(WindowInsets.Type.ime()).bottom-bars.bottom),insets.isVisible(WindowInsets.Type.ime()),bars.bottom);
}else view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets;
});
root.setFocusableInTouchMode(true);pageHost.show(canvas,motion);
applySystemBars(dark);
@@ -149,7 +158,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
private LinearLayout navigation(String title,Runnable back){
LinearLayout bar=row();bar.setPadding(dp(12),0,dp(12),0);root.addView(bar,ui.size(-1,56));
bar.addView(ui.iconButton("back","返回",this::navigateBack),ui.size(44,44));
bar.addView(ui.iconButton("back",L10n.tr("返回"),this::navigateBack),ui.size(44,44));
TextView label=text(title,17,true);label.setGravity(Gravity.CENTER);label.setSingleLine();label.setEllipsize(TextUtils.TruncateAt.END);bar.addView(label,new LinearLayout.LayoutParams(0,-1,1));
bar.addView(new View(this),ui.size(44,44));backAction=back;return bar;
}
@@ -171,14 +180,18 @@ public final class MainActivity extends Activity implements NativeSession.Listen
TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item);
return item;
}
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
private void updateHomeOrientation(){
private boolean isTablet(){
boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600;
if(android.os.Build.VERSION.SDK_INT>=30){
android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds();
tablet|=Math.min(bounds.width(),bounds.height())>=dp(600);
}
int orientation=tablet||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
return tablet;
}
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
private void updateHomeOrientation(){
remoteOrientation.end();
int orientation=isTablet()||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation);
}
private void home(String message){
@@ -187,34 +200,34 @@ public final class MainActivity extends Activity implements NativeSession.Listen
signalBadge=text("",12,true);signalBadge.setGravity(Gravity.CENTER);signalBadge.setPadding(dp(11),0,dp(11),0);signalBadge.setSingleLine(true);
signalBadge.setOnClickListener(v->{
if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);
else if(signalState==1)toast("已连接 "+serverKey());
else if(signalState==1)toast(L10n.tr("已连接 ")+serverKey());
else {stopSignaling();ensureSignaling();}
});renderSignaling();toolbar.addView(signalBadge,ui.size(-2,34));toolbar.addView(new View(this),new LinearLayout.LayoutParams(0,1,1));
FrameLayout announcementButton=(FrameLayout)ui.iconButton("envelope","通知公告",this::announcements);announcementButton.setClipChildren(false);toolbar.setClipChildren(false);toolbar.setClipToPadding(false);
FrameLayout announcementButton=(FrameLayout)ui.iconButton("envelope",L10n.tr("通知公告"),this::announcements);announcementButton.setClipChildren(false);toolbar.setClipChildren(false);toolbar.setClipToPadding(false);
announcementBadge=text("",10,true);announcementBadge.setTextColor(Color.WHITE);announcementBadge.setGravity(Gravity.CENTER);announcementBadge.setMinWidth(dp(16));announcementBadge.setPadding(dp(4),0,dp(4),0);announcementBadge.setBackground(ui.background(RED,12));announcementBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
FrameLayout.LayoutParams badgePosition=new FrameLayout.LayoutParams(-2,dp(16),Gravity.TOP|Gravity.END);badgePosition.topMargin=-dp(3);badgePosition.setMarginEnd(-dp(5));announcementButton.addView(announcementBadge,badgePosition);renderAnnouncements();
toolbar.addView(announcementButton,ui.size(40,40));View spacer=new View(this);toolbar.addView(spacer,ui.size(12,1));
FrameLayout settingsButton=(FrameLayout)ui.iconButton("settings","设置",this::settings);settingsButton.setClipChildren(false);settingsUpdateButton=settingsButton;
FrameLayout settingsButton=(FrameLayout)ui.iconButton("settings",L10n.tr("设置"),this::settings);settingsButton.setClipChildren(false);settingsUpdateButton=settingsButton;
TextView updateBadge=text("!",10,true);updateBadge.setTextColor(Color.WHITE);updateBadge.setGravity(Gravity.CENTER);updateBadge.setIncludeFontPadding(false);updateBadge.setBackground(ui.background(RED,8));updateBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);settingsUpdateDot=updateBadge;
FrameLayout.LayoutParams updatePosition=new FrameLayout.LayoutParams(dp(16),dp(16),Gravity.TOP|Gravity.END);updatePosition.topMargin=-dp(3);updatePosition.setMarginEnd(-dp(5));settingsButton.addView(settingsUpdateDot,updatePosition);
toolbar.addView(settingsButton,ui.size(40,40));renderAppUpdates();
HomeLayout layout=new HomeLayout(ui);root.addView(layout,new LinearLayout.LayoutParams(-1,0,1));LinearLayout content=layout.panels;
LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));content.addView(ui.shadowCard(connection,20));
connection.addView(text("远程桌面",20,true));ui.gap(connection,16);
connection.addView(text(L10n.tr("远程桌面"),20,true));ui.gap(connection,16);
LinearLayout entry=row();connection.addView(entry,ui.size(-1,54));
remote=field("对端 ID",InputType.TYPE_CLASS_NUMBER);remote.setTextSize(20);remote.setTypeface(Typeface.create("sans-serif-medium",Typeface.NORMAL));
remote=field(L10n.tr("对端 ID"),InputType.TYPE_CLASS_NUMBER);remote.setTextSize(20);remote.setTypeface(Typeface.create("sans-serif-medium",Typeface.NORMAL));
remote.setOnFocusChangeListener((view,focused)->{GradientDrawable shape=ui.background(BACKGROUND,12);shape.setStroke(dp(focused?2:1),focused?BLUE:LINE);remote.setBackground(shape);});
remote.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)});remote.setText(formatId(preferences.getString("lastRemote","")));
entry.addView(remote,new LinearLayout.LayoutParams(0,-1,1));
TextView connect=ui.primary("连接 →",()->promptConnection(id()));connect.setContentDescription("连接");LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
TextView connect=ui.primary(L10n.tr("连接"),()->promptConnection(id()));connect.setContentDescription(L10n.tr("连接"));LinearLayout.LayoutParams buttonParams=ui.size(96,54);buttonParams.leftMargin=dp(10);entry.addView(connect,buttonParams);
Runnable update=()->{connect.setEnabled(!id().isEmpty());connect.setAlpha(id().isEmpty()?.45f:1);};update.run();
remote.addTextChangedListener(new TextWatcher(){boolean editing;public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){update.run();}public void afterTextChanged(Editable value){if(editing)return;String formatted=formatId(value.toString());if(!formatted.equals(value.toString())){editing=true;int cursor=remote.getSelectionStart(),digits=value.subSequence(0,Math.max(0,cursor)).toString().replaceAll("[^0-9]","").length();remote.setText(formatted);remote.setSelection(Math.min(formatted.length(),digits+Math.max(0,digits-1)/3));editing=false;}}});
LinearLayout recent=column();recent.setPadding(dp(16),dp(16),dp(16),dp(16));ui.card(recent,18);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(-1,0,1);p.topMargin=dp(20);content.addView(recent,p);
JSONArray records=history.list(serverKey());recentPresence.watch(records,android.os.SystemClock.elapsedRealtime());LinearLayout heading=row();heading.addView(text("最近连接",20,true),new LinearLayout.LayoutParams(0,-2,1));
if(records.length()>0){TextView count=text(records.length()+" 台设备",12,false);count.setTextColor(SECONDARY);heading.addView(count);}recent.addView(heading);
JSONArray records=history.list(serverKey());recentPresence.watch(records,android.os.SystemClock.elapsedRealtime());LinearLayout heading=row();heading.addView(text(L10n.tr("最近连接"),20,true),new LinearLayout.LayoutParams(0,-2,1));
if(records.length()>0){TextView count=text(L10n.format("{0} 台设备",records.length()),12,false);count.setTextColor(SECONDARY);heading.addView(count);}recent.addView(heading);
if(records.length()==0){
LinearLayout empty=column();empty.setGravity(Gravity.CENTER);recent.addView(empty,new LinearLayout.LayoutParams(-1,0,1));empty.addView(ui.icon("history",0xFFB6B6BB),ui.size(34,34));ui.gap(empty,12);
TextView emptyTitle=text("还没有连接记录",17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text("成功连接后,这里会显示最近的远程设备。",13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint);
TextView emptyTitle=text(L10n.tr("还没有连接记录"),17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text(L10n.tr("成功连接后,这里会显示最近的远程设备。"),13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint);
}else{
ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));recentGrid=column();scroll.addView(recentGrid);
recentGrid.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
@@ -230,7 +243,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private void showConnectionMessage(){
if(pendingConnectionMessage.isEmpty()||!foreground||isFinishing()||isDestroyed())return;
if(connectionDialog!=null)connectionDialog.dismiss();
connectionDialog=new AlertDialog.Builder(this).setTitle("连接提示").setMessage(pendingConnectionMessage).setPositiveButton("确定",null).create();
connectionDialog=new AlertDialog.Builder(this).setTitle(L10n.tr("连接提示")).setMessage(pendingConnectionMessage).setPositiveButton(L10n.tr("确定"),null).create();
connectionDialog.setOnDismissListener(dialog->connectionDialog=null);
pendingConnectionMessage="";connectionDialog.show();
}
@@ -246,27 +259,27 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(item==null)return new View(this);String id=item.optString("id"),platform=item.optString("platform");
LinearLayout card=column();ui.card(card,14);FrameLayout preview=new FrameLayout(this){@Override protected void onMeasure(int w,int h){super.onMeasure(w,MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(w)*9/16,MeasureSpec.EXACTLY));}};
int color=platform.equals("windows")?0xFF316AB7:platform.equals("macos")?0xFF8057B9:platform.equals("linux")?0xFFAE642B:0xFF677587;preview.setBackgroundColor(color);
TextView label=text(platform.equals("windows")?"Windows":platform.equals("macos")?"macOS":platform.equals("linux")?"Linux":"远程电脑",19,true);label.setGravity(Gravity.CENTER);label.setTextColor(Color.WHITE);preview.addView(label,new FrameLayout.LayoutParams(-1,-1));card.addView(preview,ui.size(-1,80));
TextView label=text(platform.equals("windows")?"Windows":platform.equals("macos")?"macOS":platform.equals("linux")?"Linux":L10n.tr("远程电脑"),19,true);label.setGravity(Gravity.CENTER);label.setTextColor(Color.WHITE);preview.addView(label,new FrameLayout.LayoutParams(-1,-1));card.addView(preview,ui.size(-1,80));
ImageView image=new ImageView(this);image.setScaleType(ImageView.ScaleType.CENTER_CROP);image.setVisibility(View.GONE);image.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);preview.addView(image,new FrameLayout.LayoutParams(-1,-1));
String server=serverKey();Runnable load=()->{if(!image.isAttachedToWindow())return;Object binding=new Object();image.setTag(binding);image.setImageDrawable(null);image.setVisibility(View.GONE);label.setVisibility(View.VISIBLE);
previews.load(server,id,bitmap->{if(image.getTag()!=binding||!image.isAttachedToWindow()){if(bitmap!=null)bitmap.recycle();return;}if(bitmap!=null){image.setImageBitmap(bitmap);image.setVisibility(View.VISIBLE);label.setVisibility(View.GONE);}});};previewCards.add(load);image.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener(){public void onViewAttachedToWindow(View view){load.run();}public void onViewDetachedFromWindow(View view){image.setTag(null);}});if(image.isAttachedToWindow())load.run();
LinearLayout caption=column();caption.setPadding(dp(9),dp(9),dp(9),dp(9));TextView name=text(item.optString("name",id),12,true);name.setSingleLine();name.setEllipsize(TextUtils.TruncateAt.END);caption.addView(name);ui.gap(caption,4);
LinearLayout details=row();TextView detail=text("ID "+id,10,false);detail.setTypeface(Typeface.create("sans-serif",Typeface.NORMAL));detail.setTextColor(SECONDARY);detail.setSingleLine();detail.setEllipsize(TextUtils.TruncateAt.END);details.addView(detail,new LinearLayout.LayoutParams(0,-2,1));
if(item.optBoolean("remember")){View key=ui.icon("key",SECONDARY);key.setContentDescription("已保存密码");LinearLayout.LayoutParams keySize=ui.size(10,10);keySize.leftMargin=dp(4);details.addView(key,keySize);}
boolean online=recentPresence.isOnline(id,android.os.SystemClock.elapsedRealtime());String availability=online?"在线":"离线";TextView presence=text(availability,10,true);presence.setSingleLine();presence.setTextColor(online?GREEN:SECONDARY);LinearLayout.LayoutParams presenceSize=ui.size(-2,-2);presenceSize.leftMargin=dp(5);details.addView(presence,presenceSize);caption.addView(details);card.addView(caption);
card.setContentDescription("连接 "+item.optString("name",id)+",ID "+id+(item.optBoolean("remember")?",已保存密码":"")+","+availability);card.setClickable(true);card.setFocusable(true);card.setOnClickListener(v->{remote.setText(formatId(id));promptConnection(id);});
card.setOnLongClickListener(v->{new AlertDialog.Builder(this).setTitle(item.optString("name",id)).setItems(new String[]{"连接","删除记录"},(d,w)->{if(w==0)promptConnection(id);else{history.delete(serverKey(),id);home("");}}).show();return true;});return card;
if(item.optBoolean("remember")){View key=ui.icon("key",SECONDARY);key.setContentDescription(L10n.tr("已保存密码"));LinearLayout.LayoutParams keySize=ui.size(10,10);keySize.leftMargin=dp(4);details.addView(key,keySize);}
boolean online=recentPresence.isOnline(id,android.os.SystemClock.elapsedRealtime());String availability=online?L10n.tr("在线"):L10n.tr("离线");TextView presence=text(availability,10,true);presence.setSingleLine();presence.setTextColor(online?GREEN:SECONDARY);LinearLayout.LayoutParams presenceSize=ui.size(-2,-2);presenceSize.leftMargin=dp(5);details.addView(presence,presenceSize);caption.addView(details);card.addView(caption);
card.setContentDescription(L10n.tr("连接 ")+item.optString("name",id)+",ID "+id+(item.optBoolean("remember")?L10n.tr(",已保存密码"):"")+","+availability);card.setClickable(true);card.setFocusable(true);card.setOnClickListener(v->{remote.setText(formatId(id));promptConnection(id);});
card.setOnLongClickListener(v->{new AlertDialog.Builder(this).setTitle(item.optString("name",id)).setItems(new String[]{L10n.tr("连接"),L10n.tr("删除记录")},(d,w)->{if(w==0)promptConnection(id);else{history.delete(serverKey(),id);home("");}}).show();return true;});return card;
}
private void promptConnection(String id){
hideKeyboard();if(!id.matches("[0-9]{9}")){remote.setError("请输入 9 位设备 ID");remote.requestFocus();return;}
hideKeyboard();if(!id.matches("[0-9]{9}")){remote.setError(L10n.tr("请输入 9 位设备 ID"));remote.requestFocus();return;}
if(!preferences.getBoolean("networkConsent",false)){consent(()->connectOrPrompt(id));return;}connectOrPrompt(id);
}
private void connectOrPrompt(String id){
String stored;
try{stored=history.password(serverKey(),id);}catch(Exception error){passwordSheet(id,"已保存密码无法读取,请重新输入",true);return;}
try{stored=history.password(serverKey(),id);}catch(Exception error){passwordSheet(id,L10n.tr("已保存密码无法读取,请重新输入"),true);return;}
if(!stored.isEmpty()){rememberPassword=true;begin(id,stored);return;}
boolean remember=history.remembersPassword(serverKey(),id);
passwordSheet(id,remember?"已保存的密码未能保留,请重新输入一次":"",remember);
passwordSheet(id,remember?L10n.tr("已保存的密码未能保留,请重新输入一次"):"",remember);
}
private void consent(Runnable accepted){
if(privacyDialog!=null&&privacyDialog.isShowing())return;
@@ -278,15 +291,15 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private void passwordSheet(String id,String error,boolean rememberDefault){
Dialog dialog=new Dialog(this);passwordDialog=dialog;LinearLayout sheet=column();sheet.setPadding(dp(22),dp(10),dp(22),dp(22));sheet.setBackground(ui.background(BACKGROUND,24));
View handle=new View(this);handle.setBackground(ui.background(0xFFC6C6CB,3));LinearLayout.LayoutParams hp=ui.size(36,5);hp.gravity=Gravity.CENTER;sheet.addView(handle,hp);ui.gap(sheet,20);
LinearLayout header=row(),titles=column();titles.addView(text("连接远程桌面",22,true));ui.gap(titles,5);TextView subtitle=text("对端 ID "+id,15,false);subtitle.setTextColor(SECONDARY);titles.addView(subtitle);header.addView(titles,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close","关闭",dialog::dismiss),ui.size(36,36));sheet.addView(header);ui.gap(sheet,18);
LinearLayout passwordRow=row();ui.card(passwordRow,10);EditText password=field("访问密码",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD);password.setBackgroundColor(Color.TRANSPARENT);passwordRow.addView(password,new LinearLayout.LayoutParams(0,dp(50),1));
View eye=ui.iconButton("eye","显示密码",()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18);
LinearLayout save=row(),saveText=column();saveText.addView(text("保存密码",15,true));ui.gap(saveText,3);TextView security=text("密码将加密保存在本机 Android Keystore 中",11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription("保存密码");save.addView(remember);sheet.addView(save);
LinearLayout header=row(),titles=column();titles.addView(text(L10n.tr("连接远程桌面"),22,true));ui.gap(titles,5);TextView subtitle=text(L10n.format("对端 ID {0}",id),15,false);subtitle.setTextColor(SECONDARY);titles.addView(subtitle);header.addView(titles,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close",L10n.tr("关闭"),dialog::dismiss),ui.size(36,36));sheet.addView(header);ui.gap(sheet,18);
LinearLayout passwordRow=row();ui.card(passwordRow,10);EditText password=field(L10n.tr("访问密码"),InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD);password.setBackgroundColor(Color.TRANSPARENT);passwordRow.addView(password,new LinearLayout.LayoutParams(0,dp(50),1));
View eye=ui.iconButton("eye",L10n.tr("显示密码"),()->{boolean hidden=password.getTransformationMethod()!=null;password.setTransformationMethod(hidden?null:PasswordTransformationMethod.getInstance());password.setSelection(password.length());});passwordRow.addView(eye,ui.size(44,44));sheet.addView(passwordRow);ui.gap(sheet,18);
LinearLayout save=row(),saveText=column();saveText.addView(text(L10n.tr("保存密码"),15,true));ui.gap(saveText,3);TextView security=text(L10n.tr("密码将加密保存在本机 Android Keystore 中"),11,false);security.setTextColor(SECONDARY);saveText.addView(security);save.addView(saveText,new LinearLayout.LayoutParams(0,-2,1));Switch remember=new Switch(this);remember.setContentDescription(L10n.tr("保存密码"));save.addView(remember);sheet.addView(save);
remember.setChecked(rememberDefault);
ui.gap(sheet,18);TextView connect=ui.primary("连接 →",()->{
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;}
ui.gap(sheet,18);TextView connect=ui.primary(L10n.tr("连接"),()->{
String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError(L10n.tr("请输入有效访问密码"));return;}
rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret);
});connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48));
});connect.setContentDescription(L10n.tr("确认连接"));sheet.addView(connect,ui.size(-1,48));
ScrollView scroll=new ScrollView(this);scroll.addView(sheet);
dialog.setContentView(scroll);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();sizePasswordDialog();password.requestFocus();if(!error.isEmpty())password.setError(error);
dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;});
@@ -306,33 +319,40 @@ public final class MainActivity extends Activity implements NativeSession.Listen
previewCapture=previews.begin(serverKey(),id);previewCopying=false;previewAttempts=0;
connectedRemote=id;pendingPassword=rememberPassword?secret:"";ready=false;muted=false;selectedDisplay=0;hostPlatform="";remoteClipboard="";displays=new JSONArray();preferences.edit().putString("lastRemote",id).apply();
ensureSignaling();
if(signaling==null){pendingPassword="";toast("请先完成联网授权");return;}
if(signaling==null){pendingPassword="";toast(L10n.tr("请先完成联网授权"));return;}
session=signaling;showProgress();session.connect(id,secret);
}
private void showProgress(){
Dialog dialog=new Dialog(this);progressDialog=dialog;LinearLayout card=column();ui.card(card,24);card.setGravity(Gravity.CENTER_HORIZONTAL);LinearLayout body=column();body.setGravity(Gravity.CENTER);body.setPadding(dp(26),dp(24),dp(26),dp(20));
ProgressBar spinner=new ProgressBar(this);spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));body.addView(spinner,ui.size(34,34));ui.gap(body,14);TextView title=text("正在连接",20,true);title.setGravity(Gravity.CENTER);body.addView(title);ui.gap(body,6);status=text("正在连接信令服务器…",14,false);status.setGravity(Gravity.CENTER);status.setTextColor(SECONDARY);status.setMinHeight(dp(36));body.addView(status);card.addView(body,ui.size(-1,-2));ui.divider(card);card.addView(ui.action("取消连接",RED,()->end("")),ui.size(-1,46));
ProgressBar spinner=new ProgressBar(this);spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));body.addView(spinner,ui.size(34,34));ui.gap(body,14);TextView title=text(L10n.tr("正在连接"),20,true);title.setGravity(Gravity.CENTER);body.addView(title);ui.gap(body,6);status=text(L10n.tr("正在连接信令服务器…"),14,false);status.setGravity(Gravity.CENTER);status.setTextColor(SECONDARY);status.setMinHeight(dp(36));body.addView(status);card.addView(body,ui.size(-1,-2));ui.divider(card);card.addView(ui.action(L10n.tr("取消连接"),RED,()->end("")),ui.size(-1,46));
dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2);
}
private void sessionScreen(){
installRoot("session",true);setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
private void sessionScreen(boolean autoLandscape){
installRoot("session",true);remoteOrientation.begin(autoLandscape,!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
controls=new SessionControls(this,ui,new SessionControls.Actions(){
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
public void togglePrivacy(){if(ready&&session!=null)session.togglePrivacy();}
public void key(int code){tapKey(code);}
public boolean textEdit(int count,String value){return sendTextEdit(count,value);}
public void chord(int code,int[] modifiers){if(session==null)return;for(int modifier:modifiers)session.key(modifier,true);tapKey(code);for(int i=modifiers.length-1;i>=0;i--)session.key(modifiers[i],false);}public void shortcut(int code){MainActivity.this.shortcut(code);}public void text(String value){sendText(value);}
public void mute(){muted=!muted;if(session!=null)session.control(2,muted?0:1);controls.setMuted(muted);}
public void mouse(){boolean relative=!preferences.getBoolean("relativeMouse",true);preferences.edit().putBoolean("relativeMouse",relative).apply();video.setRelative(relative);controls.setRelative(relative);}
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
});controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
});controls.setHostPlatform(hostPlatform);controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.setMuted(muted);controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput));
privacyChanged();
video.remoteFocusChanged=()->{if(controls!=null)controls.resetKeyboardContext();};
controls.requestApplyInsets();
video.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{
if(!pictureInPictureUi&&!isInPictureInPictureMode())updatePictureInPicture();
});
pictureInPicture.connected();updatePictureInPicture();
}
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle(L10n.tr("断开远程连接?")).setMessage(L10n.tr("断开后将返回首页")).setNegativeButton(L10n.tr("取消"),null).setPositiveButton(L10n.tr("断开连接"),(d,w)->end("")).show();}
private void end(String reason){
dismissPrivacyUnlockDialog();
remoteOrientation.end();
if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);}
boolean closeWindow=isInPictureInPictureMode();
pictureInPicture.disconnect();updatePictureInPicture();
@@ -347,45 +367,48 @@ public final class MainActivity extends Activity implements NativeSession.Listen
appUpdates.checkNow();
}
private void settings(){
LinearLayout content=formPage("settings","设置",()->home(""));
LinearLayout mouse=section(content,"鼠标控制",MobileUi.SETTINGS_CARD_RADIUS);mouse.setPadding(dp(12),dp(12),dp(12),dp(12));boolean relative=preferences.getBoolean("relativeMouse",true);
mouse.addView(segments(new String[]{"相对位置","绝对位置"},relative?0:1,true,index->preferences.edit().putBoolean("relativeMouse",index==0).apply()));
LinearLayout picture=section(content,"画面偏好",MobileUi.SETTINGS_CARD_RADIUS);picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1)));
picture.addView(segments(RemoteVideoSettings.PREFERENCES,preference,true,index->preferences.edit().putInt("videoPreference",index).apply()));ui.gap(content,28);
LinearLayout content=formPage("settings",L10n.tr("设置"),()->home(""));
LinearLayout mouse=section(content,L10n.tr("鼠标控制"),MobileUi.SETTINGS_CARD_RADIUS);mouse.setPadding(dp(12),dp(12),dp(12),dp(12));boolean relative=preferences.getBoolean("relativeMouse",true);
mouse.addView(segments(new String[]{L10n.tr("相对位置"),L10n.tr("绝对位置")},relative?0:1,true,index->preferences.edit().putBoolean("relativeMouse",index==0).apply()));
LinearLayout picture=section(content,L10n.tr("画面偏好"),MobileUi.SETTINGS_CARD_RADIUS);picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1)));
picture.addView(segments(RemoteVideoSettings.preferences(),preference,true,index->preferences.edit().putInt("videoPreference",index).apply()));ui.gap(content,28);
LinearLayout direction=section(content,L10n.tr("屏幕方向"),MobileUi.SETTINGS_CARD_RADIUS);LinearLayout directionRow=row();directionRow.setPadding(dp(16),dp(10),dp(16),dp(10));directionRow.addView(text(L10n.tr("连接建立后锁定横屏"),16,false),new LinearLayout.LayoutParams(0,-2,1));
Switch autoLandscape=new Switch(this);autoLandscape.setContentDescription(L10n.tr("连接建立后锁定横屏"));autoLandscape.setChecked(preferences.getBoolean("autoLandscape",false));autoLandscape.setOnCheckedChangeListener((button,checked)->preferences.edit().putBoolean("autoLandscape",checked).apply());directionRow.addView(autoLandscape);direction.addView(directionRow);
footer(content,L10n.tr("默认关闭。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受影响。"));
LinearLayout server=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099;
link(server,"服务器",custom?"自定义":"默认",()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields);
LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text("使用自定义服务器",16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription("使用自定义服务器");toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow);
LinearLayout inputs=column();inputs.setPadding(dp(12),0,dp(12),dp(12));EditText address=field("服务器地址",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_URI),number=field("服务器端口",InputType.TYPE_CLASS_NUMBER);address.setText(custom?host:"");number.setText(custom?String.valueOf(port):"");inputs.addView(address,ui.size(-1,48));ui.gap(inputs,8);inputs.addView(number,ui.size(-1,48));ui.gap(inputs,8);
inputs.addView(ui.action("完成",BLUE,()->{if(saveServer(address,number)){hideKeyboard();settings();}}));fields.addView(inputs);inputs.setVisibility(custom?View.VISIBLE:View.GONE);
link(server,L10n.tr("服务器"),custom?L10n.tr("自定义"):L10n.tr("默认"),()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields);
LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text(L10n.tr("使用自定义服务器"),16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription(L10n.tr("使用自定义服务器"));toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow);
LinearLayout inputs=column();inputs.setPadding(dp(12),0,dp(12),dp(12));EditText address=field(L10n.tr("服务器地址"),InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_URI),number=field(L10n.tr("服务器端口"),InputType.TYPE_CLASS_NUMBER);address.setText(custom?host:"");number.setText(custom?String.valueOf(port):"");inputs.addView(address,ui.size(-1,48));ui.gap(inputs,8);inputs.addView(number,ui.size(-1,48));ui.gap(inputs,8);
inputs.addView(ui.action(L10n.tr("完成"),BLUE,()->{if(saveServer(address,number)){hideKeyboard();settings();}}));fields.addView(inputs);inputs.setVisibility(custom?View.VISIBLE:View.GONE);
toggle.setOnCheckedChangeListener((button,checked)->{inputs.setVisibility(checked?View.VISIBLE:View.GONE);if(!checked){host="api.crossdesk.cn";port=9099;preferences.edit().putString("host",host).putInt("port",port).apply();ensureSignaling();} });
backAction=()->{if(toggle.isChecked()&&!saveServer(address,number))return;hideKeyboard();home("");};
identitySummary=text("",12,false);identitySummary.setTextColor(SECONDARY);identitySummary.setGravity(Gravity.CENTER);identitySummary.setPadding(dp(16),dp(12),dp(16),dp(16));identitySummary.setTextIsSelectable(true);content.addView(identitySummary);renderSignaling();
LinearLayout privacy=section(content,"隐私",MobileUi.SETTINGS_CARD_RADIUS);link(privacy,"隐私与授权",preferences.getBoolean("networkConsent",false)?"已授权":"未授权",this::privacyPage);ui.divider(privacy);
LinearLayout preview=row();preview.setPadding(dp(16),dp(10),dp(16),dp(10));preview.addView(text("保存远程画面预览",16,false),new LinearLayout.LayoutParams(0,-2,1));previewSwitch=new Switch(this);previewSwitch.setContentDescription("保存远程画面预览");preview.addView(previewSwitch);privacy.addView(preview);
LinearLayout privacy=section(content,L10n.tr("隐私"),MobileUi.SETTINGS_CARD_RADIUS);link(privacy,L10n.tr("隐私与授权"),preferences.getBoolean("networkConsent",false)?L10n.tr("已授权"):L10n.tr("未授权"),this::privacyPage);ui.divider(privacy);
LinearLayout preview=row();preview.setPadding(dp(16),dp(10),dp(16),dp(10));preview.addView(text(L10n.tr("保存远程画面预览"),16,false),new LinearLayout.LayoutParams(0,-2,1));previewSwitch=new Switch(this);previewSwitch.setContentDescription(L10n.tr("保存远程画面预览"));preview.addView(previewSwitch);privacy.addView(preview);
previewSwitch.setOnCheckedChangeListener((button,checked)->{if(updatingPreviewSwitch)return;if(checked){renderPreviews();confirmPreviewSaving();}else previews.setEnabled(false);});
ui.divider(privacy);clearPreviewsButton=ui.action("清除预览图",RED,this::confirmClearPreviews);clearPreviewsButton.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);clearPreviewsButton.setTypeface(Typeface.DEFAULT);clearPreviewsButton.setPadding(dp(16),0,dp(16),0);privacy.addView(clearPreviewsButton,ui.size(-1,48));
ui.divider(privacy);clearPreviewsButton=ui.action(L10n.tr("清除预览图"),RED,this::confirmClearPreviews);clearPreviewsButton.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);clearPreviewsButton.setTypeface(Typeface.DEFAULT);clearPreviewsButton.setPadding(dp(16),0,dp(16),0);privacy.addView(clearPreviewsButton,ui.size(-1,48));
previewCleanupError=text("",12,false);previewCleanupError.setTextColor(RED);previewCleanupError.setPadding(dp(16),dp(8),dp(16),dp(12));privacy.addView(previewCleanupError);renderPreviews();
ui.gap(content,28);LinearLayout about=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout aboutLink=link(about,"关于","新版本",this::about);aboutUpdateRow=aboutLink;
ui.gap(content,28);LinearLayout about=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout aboutLink=link(about,L10n.tr("关于"),L10n.tr("新版本"),this::about);aboutUpdateRow=aboutLink;
aboutUpdateLabel=(TextView)aboutLink.getChildAt(1);aboutUpdateLabel.setPadding(0,0,dp(8),0);
aboutUpdateDot=updateDot();LinearLayout.LayoutParams dotPosition=new LinearLayout.LayoutParams(dp(8),dp(8));dotPosition.setMarginEnd(dp(8));aboutLink.addView(aboutUpdateDot,2,dotPosition);renderAppUpdates();
}
private void confirmPreviewSaving(){
previewDialog=new AlertDialog.Builder(this).setTitle("保存远程画面预览?")
.setMessage("预览可能包含远程电脑上的个人或工作信息。开启后,每次连接会自动将一张画面保存在本机并显示在最近连接中。你可以随时关闭并清除。")
.setNegativeButton("取消",null).setPositiveButton("开启保存",(d,w)->previews.setEnabled(true)).show();
previewDialog=new AlertDialog.Builder(this).setTitle(L10n.tr("保存远程画面预览?"))
.setMessage(L10n.tr("预览可能包含远程电脑上的个人或工作信息。开启后,每次连接会自动将一张画面保存在本机并显示在最近连接中。你可以随时关闭并清除。"))
.setNegativeButton(L10n.tr("取消"),null).setPositiveButton(L10n.tr("开启保存"),(d,w)->previews.setEnabled(true)).show();
previewDialog.setOnDismissListener(d->{previewDialog=null;renderPreviews();});
}
private void confirmClearPreviews(){
previewDialog=new AlertDialog.Builder(this).setTitle("清除预览图?")
.setMessage("将删除本机保存的所有远程预览图,保留连接记录和密码。"+(previews.enabled()?"后续连接仍会保存新的预览图。":""))
.setNegativeButton("取消",null).setPositiveButton("清除",(d,w)->previews.clear()).show();
previewDialog=new AlertDialog.Builder(this).setTitle(L10n.tr("清除预览图?"))
.setMessage(L10n.tr("将删除本机保存的所有远程预览图,保留连接记录和密码。")+(previews.enabled()?L10n.tr("后续连接仍会保存新的预览图。"):""))
.setNegativeButton(L10n.tr("取消"),null).setPositiveButton(L10n.tr("清除"),(d,w)->previews.clear()).show();
previewDialog.setOnDismissListener(d->previewDialog=null);
}
private void renderPreviews(){
if(previews==null)return;
if(!previews.enabled()||previews.cleaning)previewCapture=null;
if(previewSwitch!=null){updatingPreviewSwitch=true;previewSwitch.setChecked(previews.enabled());previewSwitch.setEnabled(preferences.getBoolean("networkConsent",false)&&!previews.cleaning);updatingPreviewSwitch=false;}
if(clearPreviewsButton!=null){clearPreviewsButton.setText(previews.cleaning?"正在清除…":"清除预览图");clearPreviewsButton.setEnabled(!previews.cleaning);}
if(clearPreviewsButton!=null){clearPreviewsButton.setText(previews.cleaning?L10n.tr("正在清除…"):L10n.tr("清除预览图"));clearPreviewsButton.setEnabled(!previews.cleaning);}
if(previewCleanupError!=null){previewCleanupError.setText(previews.cleanupError);previewCleanupError.setVisibility(previews.cleanupError.isEmpty()?View.GONE:View.VISIBLE);}
for(Runnable card:previewCards)card.run();
}
@@ -404,17 +427,17 @@ public final class MainActivity extends Activity implements NativeSession.Listen
return new SegmentedControl(ui,titles,selected,enabled,changed);
}
private boolean saveServer(EditText address,EditText number){
String name=address.getText().toString().trim();int value;try{value=Integer.parseInt(number.getText().toString());}catch(NumberFormatException e){number.setError("请输入 1–65535 范围内的端口");return false;}
if(name.length()>253||!name.matches("[A-Za-z0-9.-]+")||name.startsWith(".")||name.endsWith(".")){address.setError("填写主机名或 IPv4 地址,不含协议和路径");return false;}if(value<1||value>65535){number.setError("端口范围为 1–65535");return false;}host=name;port=value;preferences.edit().putString("host",host).putInt("port",port).apply();ensureSignaling();return true;
String name=address.getText().toString().trim();int value;try{value=Integer.parseInt(number.getText().toString());}catch(NumberFormatException e){number.setError(L10n.tr("请输入 1–65535 范围内的端口"));return false;}
if(name.length()>253||!name.matches("[A-Za-z0-9.-]+")||name.startsWith(".")||name.endsWith(".")){address.setError(L10n.tr("填写主机名或 IPv4 地址,不含协议和路径"));return false;}if(value<1||value>65535){number.setError(L10n.tr("端口范围为 1–65535"));return false;}host=name;port=value;preferences.edit().putString("host",host).putInt("port",port).apply();ensureSignaling();return true;
}
private void announcements(){
installRoot("announcements",false);LinearLayout bar=navigation("通知公告",()->home(""));
bar.removeViewAt(bar.getChildCount()-1);announcementRefresh=ui.iconButton("refresh","刷新公告",announcementInbox::refresh);bar.addView(announcementRefresh,ui.size(44,44));
installRoot("announcements",false);LinearLayout bar=navigation(L10n.tr("通知公告"),()->home(""));
bar.removeViewAt(bar.getChildCount()-1);announcementRefresh=ui.iconButton("refresh",L10n.tr("刷新公告"),announcementInbox::refresh);bar.addView(announcementRefresh,ui.size(44,44));
announcementView=new AnnouncementView(ui,announcementInbox,null,this::announcementDetail,this::connectAnnouncements);
root.addView(announcementView,new LinearLayout.LayoutParams(-1,0,1));announcementInbox.refresh();renderAnnouncements();
}
private void announcementDetail(AnnouncementInbox.Item item){
installRoot("announcement-detail",false);navigation("公告详情",this::announcements);
installRoot("announcement-detail",false);navigation(L10n.tr("公告详情"),this::announcements);
announcementView=new AnnouncementView(ui,announcementInbox,item,this::announcementDetail,this::connectAnnouncements);
root.addView(announcementView,new LinearLayout.LayoutParams(-1,0,1));announcementInbox.markRead(item);
}
@@ -424,7 +447,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
private void renderAnnouncements(){
if(announcementBadge!=null){int unread=announcementInbox.unread;announcementBadge.setText(unread>99?"99+":String.valueOf(unread));announcementBadge.setVisibility(unread>0?View.VISIBLE:View.GONE);
if(android.os.Build.VERSION.SDK_INT>=30)((View)announcementBadge.getParent()).setStateDescription(unread>0?unread+" 条未读":"无未读公告");}
if(android.os.Build.VERSION.SDK_INT>=30)((View)announcementBadge.getParent()).setStateDescription(unread>0?L10n.format("{0} 条未读",unread):L10n.tr("无未读公告"));}
if(announcementRefresh!=null){boolean enabled=announcementInbox.isConnected()&&!announcementInbox.loading;announcementRefresh.setEnabled(enabled);announcementRefresh.setAlpha(enabled?1:.4f);}
if(announcementView!=null)announcementView.render();
}
@@ -434,12 +457,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
@Override public void announcementFailed(String requestId){announcementInbox.failed(requestId);}
private void privacyPage(){
LinearLayout content=formPage("privacy","隐私与授权",this::settings);LinearLayout card=section(content,"");boolean consent=preferences.getBoolean("networkConsent",false);link(card,"授权状态",consent?"已授权":"未授权",()->{});ui.divider(card);link(card,"隐私政策","",this::privacyPolicyPage);
footer(content,"设备身份及选择保存的访问密码使用 Android Keystore 加密。连接记录仅保存在本机,不纳入设备备份。剪贴板内容在结束会话时清除。离开应用后通过画中画或连接通知保持会话,可在应用或通知中断开。");
if(consent)content.addView(ui.action("撤回联网授权",RED,()->new AlertDialog.Builder(this).setTitle("撤回联网授权?").setMessage("撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。").setNegativeButton("取消",null).setPositiveButton("撤回",(d,w)->{preferences.edit().putBoolean("networkConsent",false).apply();appUpdates.setEnabled(false);previews.setEnabled(false);stopSignaling();announcementInbox.reset();privacyPage();}).show()));else content.addView(ui.primary("阅读并授权",()->consent(this::privacyPage)));
LinearLayout content=formPage("privacy",L10n.tr("隐私与授权"),this::settings);LinearLayout card=section(content,"");boolean consent=preferences.getBoolean("networkConsent",false);link(card,L10n.tr("授权状态"),consent?L10n.tr("已授权"):L10n.tr("未授权"),()->{});ui.divider(card);link(card,L10n.tr("隐私政策"),"",this::privacyPolicyPage);
footer(content,L10n.tr("设备身份及选择保存的访问密码使用 Android Keystore 加密。连接记录仅保存在本机,不纳入设备备份。剪贴板内容在结束会话时清除。离开应用后通过画中画或连接通知保持会话,可在应用或通知中断开。"));
if(consent)content.addView(ui.action(L10n.tr("撤回联网授权"),RED,()->new AlertDialog.Builder(this).setTitle(L10n.tr("撤回联网授权?")).setMessage(L10n.tr("撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。")).setNegativeButton(L10n.tr("取消"),null).setPositiveButton(L10n.tr("撤回"),(d,w)->{preferences.edit().putBoolean("networkConsent",false).apply();appUpdates.setEnabled(false);previews.setEnabled(false);stopSignaling();announcementInbox.reset();privacyPage();}).show()));else content.addView(ui.primary(L10n.tr("阅读并授权"),()->consent(this::privacyPage)));
}
private void privacyPolicyPage(){
installRoot("privacy-policy",false);navigation("隐私政策",this::privacyPage);
installRoot("privacy-policy",false);navigation(L10n.tr("隐私政策"),this::privacyPage);
root.addView(new PrivacyPolicyDocument(this).view(ui),new LinearLayout.LayoutParams(-1,0,1));
}
String applicationVersion(){
@@ -456,22 +479,27 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(settingsUpdateDot!=null)settingsUpdateDot.setVisibility(available?View.VISIBLE:View.GONE);
if(aboutUpdateDot!=null)aboutUpdateDot.setVisibility(available?View.VISIBLE:View.GONE);
if(aboutUpdateLabel!=null)aboutUpdateLabel.setVisibility(available?View.VISIBLE:View.GONE);
if(settingsUpdateButton!=null)settingsUpdateButton.setContentDescription(available?"设置,有新版本":"设置");
if(aboutUpdateRow!=null)aboutUpdateRow.setContentDescription(available?"关于,有新版本":"关于");
if(settingsUpdateButton!=null)settingsUpdateButton.setContentDescription(available?L10n.tr("设置,有新版本"):L10n.tr("设置"));
if(aboutUpdateRow!=null)aboutUpdateRow.setContentDescription(available?L10n.tr("关于,有新版本"):L10n.tr("关于"));
if(aboutPages!=null&&page.equals("about"))aboutPages.renderUpdates();
}
private void about(){
if(aboutPages==null)aboutPages=new AboutPages(this,ui,this::settings,appUpdates);aboutPages.show();
}
private void sendText(String value){if(session==null)return;if(value.length()>2048){toast("单次最多 2048 个字符");return;}for(char ch:value.toCharArray())if(KeyMap.ascii(ch)==0){toast("中文等文本请通过剪贴板发送,再点击粘贴");return;}for(char ch:value.toCharArray()){int code=KeyMap.ascii(ch);boolean shift=(code&0x100)!=0;if(shift)session.key(0x10,true);tapKey(code&0xFF);if(shift)session.key(0x10,false);}}
private void sendText(String value){sendTextEdit(0,value);}
private boolean sendTextEdit(int count,String value){
if(session==null)return false;
if(session.textEdit(count,value))return true;
toast(L10n.tr("当前远端不支持直接输入此文本,请使用支持文本输入的被控端版本,或通过剪贴板发送"));return false;
}
private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}}
private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}}
private void selectDisplay(){
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast("等待远端屏幕信息");return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?"显示器":display.optString("name","显示器"));}new AlertDialog.Builder(this).setTitle("显示器").setItems(labels,(d,w)->{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show();
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast(L10n.tr("等待远端屏幕信息"));return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?L10n.tr("显示器"):display.optString("name",L10n.tr("显示器")));}new AlertDialog.Builder(this).setTitle(L10n.tr("显示器")).setItems(labels,(d,w)->{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show();
}
private void secureAttention(){
if(!ready||session==null)return;
if(!remoteServiceAvailable){controlFeedback(hostPlatform.equals("windows")?"请在远端启用 CrossDesk 系统服务后重试":"Ctrl+Alt+Del 需要远端 Windows 系统服务支持");return;}
if(!ready||session==null||!hostPlatform.equals("windows"))return;
if(!remoteServiceAvailable){controlFeedback(L10n.tr("请在远端启用 CrossDesk 系统服务后重试"));return;}
session.secureAttention();
}
private void chooseFile(){
@@ -488,7 +516,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
documentSession=session;releaseKeys();
pictureInPicture.choosingDocument(true);updatePictureInPicture();
try{startActivityForResult(intent,request);mainHandler.postDelayed(documentTimeout,5*60*1000);}
catch(android.content.ActivityNotFoundException error){documentSession=null;documentToSave=null;pictureInPicture.choosingDocument(false);updatePictureInPicture();controlFeedback("此设备没有可用的文件选择器");}
catch(android.content.ActivityNotFoundException error){documentSession=null;documentToSave=null;pictureInPicture.choosingDocument(false);updatePictureInPicture();controlFeedback(L10n.tr("此设备没有可用的文件选择器"));}
}
@Override protected void onActivityResult(int request,int result,Intent data){
super.onActivityResult(request,result,data);
@@ -502,7 +530,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
@Override public void fileTransfer(String name,double progress,boolean sending,java.io.File received){
if(!ready)return;
if(received!=null)receivedFile=received;
String fileStatus=progress<0?name+" 传输失败":(sending?"发送 ":"接收 ")+name+(progress>=1?" · 已完成":" · "+(int)(progress*100)+"%");
String fileStatus=progress<0?L10n.format("{0} 传输失败",name):(sending?L10n.tr("发送 "):L10n.tr("接收 "))+name+(progress>=1?L10n.tr(" · 已完成"):" · "+(int)(progress*100)+"%");
if(controls!=null)controls.fileStatus(fileStatus,receivedFile!=null);
if(progress<0||progress>=1)toast(fileStatus);
}
@@ -511,14 +539,14 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
private void clipboard(){
if(!ready)return;
EditText input=field("要发送到远端的文本",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_FLAG_MULTI_LINE);input.setSingleLine(false);input.setMinLines(3);
new AlertDialog.Builder(this).setTitle("剪贴板").setView(input)
.setMessage("发送后,在远程键盘中点击“粘贴”。接收的远端文本可手动复制到本机。")
.setNegativeButton("关闭",null).setNeutralButton("复制远端文本",(d,w)->{
EditText input=field(L10n.tr("要发送到远端的文本"),InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_FLAG_MULTI_LINE);input.setSingleLine(false);input.setMinLines(3);
new AlertDialog.Builder(this).setTitle(L10n.tr("剪贴板")).setView(input)
.setMessage(L10n.tr("发送后,在远程键盘中点击“粘贴”。接收的远端文本可手动复制到本机。"))
.setNegativeButton(L10n.tr("关闭"),null).setNeutralButton(L10n.tr("复制远端文本"),(d,w)->{
if(!remoteClipboard.isEmpty())((ClipboardManager)getSystemService(CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("CrossDesk",remoteClipboard));
}).setPositiveButton("发送文本",(d,w)->{
}).setPositiveButton(L10n.tr("发送文本"),(d,w)->{
String value=input.getText().toString();
if(value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>128*1024){Toast.makeText(this,"文本超过 128 KB",Toast.LENGTH_SHORT).show();return;}
if(value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length>128*1024){Toast.makeText(this,L10n.tr("文本超过 128 KB"),Toast.LENGTH_SHORT).show();return;}
if(session!=null)session.clipboard(value);
}).show();
}
@@ -553,17 +581,17 @@ public final class MainActivity extends Activity implements NativeSession.Listen
boolean consent=preferences.getBoolean("networkConsent",false);
boolean connected=consent&&signalError.isEmpty()&&signalState==1;
String label;int tint,background;
if(!consent){label="等待隐私授权";tint=0xFFB76A00;background=0xFFFFEBD2;}
else if(!signalError.isEmpty()){label=signalError.contains("TLS")?"证书校验失败 · 点击重试":"连接失败 · 点击重试";tint=RED;background=0xFFFFE8E6;}
else if(connected){label="已连接服务器";tint=GREEN;background=0xFFE5F4E8;}
else{label=signalState==0?"正在连接服务器…":signalState==4?"正在重连服务器…":"未连接服务器 · 点击重试";tint=0xFFB76A00;background=0xFFFFEBD2;}
if(!consent){label=L10n.tr("等待隐私授权");tint=0xFFB76A00;background=0xFFFFEBD2;}
else if(!signalError.isEmpty()){label=signalError.contains("TLS")?L10n.tr("证书校验失败 · 点击重试"):L10n.tr("连接失败 · 点击重试");tint=RED;background=0xFFFFE8E6;}
else if(connected){label=L10n.tr("已连接服务器");tint=GREEN;background=0xFFE5F4E8;}
else{label=signalState==0?L10n.tr("正在连接服务器…"):signalState==4?L10n.tr("正在重连服务器…"):L10n.tr("未连接服务器 · 点击重试");tint=0xFFB76A00;background=0xFFFFEBD2;}
if(signalBadge!=null){
android.graphics.drawable.Drawable icon=connected?getDrawable(R.drawable.ic_check_circle):null;
if(icon!=null){int size=dp(14);icon.setBounds(0,0,size,size);}
signalBadge.setCompoundDrawablesRelative(icon,null,null,null);signalBadge.setCompoundDrawablePadding(dp(6));
signalBadge.setText(label);signalBadge.setTextColor(tint);signalBadge.setBackground(ui.background(background,20));signalBadge.setContentDescription(signalError.isEmpty()?label:signalError+",点击重试");
signalBadge.setText(label);signalBadge.setTextColor(tint);signalBadge.setBackground(ui.background(background,20));signalBadge.setContentDescription(signalError.isEmpty()?label:signalError+L10n.tr(",点击重试"));
}
if(identitySummary!=null)identitySummary.setText(!consent?"完成隐私授权后登记本机身份":!localIdentity.isEmpty()?"本机 ID "+localIdentity:!signalError.isEmpty()?signalError:"正在获取本机 ID…");
if(identitySummary!=null)identitySummary.setText(!consent?L10n.tr("完成隐私授权后登记本机身份"):!localIdentity.isEmpty()?L10n.format("本机 ID {0}",localIdentity):!signalError.isEmpty()?signalError:L10n.tr("正在获取本机 ID…"));
}
@Override public void signaling(int state,String deviceId){
signalState=state;localIdentity=deviceId;signalError="";renderSignaling();
@@ -578,7 +606,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
// Transport readiness can be reported again after ICE changes its selected path.
// The first success consumes pendingPassword; a duplicate must not save it again.
if(session==null||ready)return;ready=true;if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen();
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast(L10n.tr("密码保存失败,本次连接仍可使用"));}pendingPassword="";sessionScreen(preferences.getBoolean("autoLandscape",false));
if(!canUsePictureInPicture())prepareBackgroundSession(true);
if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection);
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected();
@@ -592,12 +620,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
String id=connectedRemote;boolean remember=rememberPassword;
end("");
// Let the user replace an expired credential; never retry it automatically.
if(foreground)passwordSheet(id,"访问密码错误,请重新输入",remember);
if(foreground)passwordSheet(id,L10n.tr("访问密码错误,请重新输入"),remember);
}
private void cancelVideoSettingsTimeout(){if(videoSettingsTimeout!=null)mainHandler.removeCallbacks(videoSettingsTimeout);videoSettingsTimeout=null;}
private void showRemoteUpdate(){
if(!ready||session==null||!foreground||pictureInPictureUi||isInPictureInPictureMode()||isFinishing()||!preferences.getBoolean("networkConsent",false))return;
remoteUpdateDialog=new AlertDialog.Builder(this).setTitle("升级提示").setMessage(RemoteVersionCheck.NOTICE).setPositiveButton("知道了",null).create();
remoteUpdateDialog=new AlertDialog.Builder(this).setTitle(L10n.tr("升级提示")).setMessage(L10n.tr(RemoteVersionCheck.NOTICE)).setPositiveButton(L10n.tr("知道了"),null).create();
remoteUpdateDialog.setOnDismissListener(dialog->remoteUpdateDialog=null);
remoteUpdateDialog.setCanceledOnTouchOutside(false);
remoteUpdateDialog.show();
@@ -624,6 +652,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
JSONObject info=message.optJSONObject("host_info");
if(info!=null){
hostPlatform=info.optString("platform","");JSONArray list=info.optJSONArray("displays");if(list!=null)displays=list;history.metadata(serverKey(),connectedRemote,info.optString("host_name",connectedRemote),hostPlatform);
if(controls!=null)controls.setHostPlatform(hostPlatform);
boolean wasSupported=videoSettings.supported;videoSettings.setSupported(info.optBoolean("supports_video_settings",false));
if(!videoSettings.supported)cancelVideoSettingsTimeout();
if(controls!=null)controls.videoSettings(videoSettings);
@@ -631,21 +660,63 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
}
}
private void dismissPrivacyUnlockDialog() {
if (privacyUnlockDialog != null) { privacyUnlockDialog.dismiss(); privacyUnlockDialog = null; }
}
@Override public void privacyChanged() {
if (controls != null && session != null) controls.privacyScreen(session.privacyScreen);
if (!ready || session == null || !session.privacyScreen.unlockNotice) {
dismissPrivacyUnlockDialog(); return;
}
if (!foreground || pictureInPictureUi || isInPictureInPictureMode() || isFinishing()) return;
if (privacyUnlockDialog == null) {
releaseKeys();
if (controls != null) { controls.closeKeyboard(); controls.cancelMouseInput(); }
if (video != null) video.releaseMouse();
privacyUnlockDialog = new AlertDialog.Builder(this).setTitle(L10n.tr("隐私屏已被解锁"))
.setMessage(L10n.tr("被控端已通过组合键解除隐私屏。点击“锁定”将重新开启隐私屏,并禁止本机使用组合键解锁。"))
.setNegativeButton(L10n.tr("确认"), (dialog, which) -> {
if (session != null) session.privacyScreen.unlockNotice = false;
dismissPrivacyUnlockDialog();
}).setPositiveButton(L10n.tr("锁定"), null).create();
privacyUnlockDialog.setCancelable(false);
privacyUnlockDialog.show();
privacyUnlockDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> {
if (session != null) session.lockPrivacy();
});
}
String feedback = session.privacyScreen.feedback(android.os.SystemClock.elapsedRealtime());
privacyUnlockDialog.setMessage(L10n.tr("被控端已通过组合键解除隐私屏。点击“锁定”将重新开启隐私屏,并禁止本机使用组合键解锁。")
+ (feedback.isEmpty() ? "" : "\n\n" + feedback));
privacyUnlockDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(
session.privacyScreen.canLock(android.os.SystemClock.elapsedRealtime()));
}
@Override public void videoSize(int width,int height){videoWidth=width;videoHeight=height;if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);pictureInPicture.videoSize(width,height);updatePictureInPicture();}
@Override public void clipboard(String value){remoteClipboard=value;}
@Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();}
@Override public boolean dispatchKeyEvent(KeyEvent event){
if(ready&&session!=null&&!pictureInPictureUi&&hasWindowFocus()&&!(getCurrentFocus() instanceof EditText)&&event.getKeyCode()!=KeyEvent.KEYCODE_BACK){int code=KeyMap.windowsCode(event.getKeyCode());if(code!=0){boolean down=event.getAction()==KeyEvent.ACTION_DOWN;if(down)pressed.add(code);else pressed.remove(code);session.key(code,down);return true;}}return super.dispatchKeyEvent(event);
if (privacyUnlockDialog != null) return super.dispatchKeyEvent(event);
int code=KeyMap.windowsCode(event.getKeyCode());View focus=getCurrentFocus();
boolean remoteEditor=focus instanceof RemoteKeyboardInput;
boolean desktopKey=event.isCtrlPressed()||event.isAltPressed()||event.isMetaPressed()||pressed.contains(code)
||code==17||code==18||code==91||code==27||code==9||code>=33&&code<=40||code==45||code==46||code>=112&&code<=123;
if(ready&&session!=null&&!pictureInPictureUi&&hasWindowFocus()&&(!(focus instanceof EditText)||remoteEditor&&(!((RemoteKeyboardInput)focus).isAcceptingInput()||desktopKey))&&event.getKeyCode()!=KeyEvent.KEYCODE_BACK&&code!=0){
boolean down=event.getAction()==KeyEvent.ACTION_DOWN;
if(down){pressed.add(code);if(remoteEditor)controls.resetKeyboardContext();}else pressed.remove(code);
session.key(code,down);return true;
}
return super.dispatchKeyEvent(event);
}
private void releaseKeys(){if(session!=null)for(int code:pressed)session.key(code,false);pressed.clear();}
private void navigateBack(){if(backAction==null)return;navigatingBack=true;try{backAction.run();}finally{navigatingBack=false;}}
private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
private void back(){if(controls!=null&&controls.closeKeyboard())return;pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();}
@Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);}
@android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();}
@Override public void onConfigurationChanged(Configuration config){
super.onConfigurationChanged(config);
releaseKeys();
if(!page.equals("session"))updateHomeOrientation();
else if(!isInPictureInPictureMode())remoteOrientation.update(!isTablet()&&!isInMultiWindowMode());
sizePasswordDialog();
if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize();
if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels);
@@ -664,6 +735,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable;
retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile;
retained.width=videoWidth;retained.height=videoHeight;
retained.autoLandscape=remoteOrientation.autoLandscape();
}
private boolean prepareBackgroundSession(boolean inform){
if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false;
@@ -672,10 +744,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen
backgroundSession=retained;captureBackgroundSession();retained.attach(this);session.setListener(retained);
if(!RemoteSessionService.retain(this,retained)){
session.setListener(this);retained.detach(this);backgroundSession=null;
if(inform)toast("后台保活未能启动,请保持应用在前台");return false;
if(inform)toast(L10n.tr("后台保活未能启动,请保持应用在前台"));return false;
}
if(inform){
toast("已启用后台保活,可从连接通知返回或断开");
toast(L10n.tr("已启用后台保活,可从连接通知返回或断开"));
requestBackgroundNotifications();
}
return true;
@@ -690,7 +762,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
BackgroundSession retained=backgroundSession!=null?backgroundSession:RemoteSessionService.current();
if(retained==null)return false;
if(retained.ended||retained.session.isClosed()||!hasNetworkConsent()||!host.equals(retained.host)||port!=retained.port){
retained.detach(this);retained.finish("后台连接已结束,请重新连接");backgroundSession=null;
retained.detach(this);retained.finish(L10n.tr("后台连接已结束,请重新连接"));backgroundSession=null;
if(session==retained.session)end(retained.endReason);return false;
}
boolean rebuild=session!=retained.session||video==null||!page.equals("session");
@@ -699,9 +771,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay;
remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile;
videoSettings=retained.videoSettings;ready=true;retained.attach(this);
if(rebuild)sessionScreen();
if(rebuild)sessionScreen(retained.autoLandscape);
if(retained.width>0&&retained.height>0)videoSize(retained.width,retained.height);
if(controls!=null){controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
if(controls!=null){controls.setHostPlatform(hostPlatform);controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
retained.setBackground(false);signaling(signalState,localIdentity);
return true;
}
@@ -747,11 +819,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onPictureInPictureUiStateChanged(state);
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
}
@Override protected void onResume(){super.onResume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
@Override protected void onResume(){super.onResume();privacyChanged();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onPause(){releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();}
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
@Override protected void onStop(){dismissPrivacyUnlockDialog();if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
if(connectionDialog!=null)connectionDialog.dismiss();
// Closing native PiP is an explicit end, not a request for invisible
// fallback. An unavailable/failed PiP can hand its live session off.
@@ -760,12 +832,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
mainHandler.removeCallbacks(presenceTick);recentPresence.setConnected(false,android.os.SystemClock.elapsedRealtime());announcementInbox.setConnected(false);
cancelVideoSettingsTimeout();if(videoSettings.pending)videoSettings.expire(videoSettings.requestId);
resetRemoteVersionCheck();if(disconnectDialog!=null)disconnectDialog.dismiss();
}else if(documentSession==null||documentSession!=session||isFinishing()){if(session!=null)end(isFinishing()?"":"远程窗口已关闭,或后台保活未能启动,会话已结束");stopSignaling();}
}else if(documentSession==null||documentSession!=session||isFinishing()){if(session!=null)end(isFinishing()?"":L10n.tr("远程窗口已关闭,或后台保活未能启动,会话已结束"));stopSignaling();}
super.onStop();}
@Override protected void onDestroy(){mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();
@Override protected void onDestroy(){dismissPrivacyUnlockDialog();mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();
if(connectionDialog!=null)connectionDialog.dismiss();
boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
if(backgroundSession!=null)backgroundSession.detach(this);
if(!retained){if(session!=null)session.close();stopSignaling();}else{mainHandler.removeCallbacks(presenceTick);session=signaling=null;}
super.onDestroy();}
remoteOrientation.end();super.onDestroy();}
}
@@ -52,6 +52,7 @@ final class MobileUi {
}
TextView primary(String title,Runnable click) {
TextView v=action(title,Color.WHITE,click);
v.setMaxLines(2);v.setAutoSizeTextTypeUniformWithConfiguration(11,16,1,android.util.TypedValue.COMPLEX_UNIT_SP);
GradientDrawable gradient=new GradientDrawable(GradientDrawable.Orientation.TL_BR,new int[]{BLUE,0xFF1F61C7});gradient.setCornerRadius(dp(12));v.setBackground(gradient);return v;
}
View iconButton(String icon,String label,Runnable click) {
@@ -59,7 +60,7 @@ final class MobileUi {
Icon glyph=new Icon(context,icon,INK);FrameLayout.LayoutParams p=new FrameLayout.LayoutParams(dp(22),dp(22),Gravity.CENTER);box.addView(glyph,p);
box.setContentDescription(label);box.setClickable(true);box.setFocusable(true);box.setOnClickListener(v->click.run());return box;
}
View icon(String name,int color) { return new Icon(context,name,color); }
Icon icon(String name,int color) { return new Icon(context,name,color); }
void gap(LinearLayout parent,int height){parent.addView(new View(context),new LinearLayout.LayoutParams(1,dp(height)));}
void divider(LinearLayout parent){View line=new View(context);line.setBackgroundColor(LINE);parent.addView(line,new LinearLayout.LayoutParams(-1,dp(.5f)));}
LinearLayout.LayoutParams size(int width,int height){return new LinearLayout.LayoutParams(width<0?width:dp(width),height<0?height:dp(height));}
@@ -93,8 +94,9 @@ final class MobileUi {
}
@android.annotation.SuppressLint("ViewConstructor") // Programmatic icon primitive, not inflated from XML.
static final class Icon extends View {
private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG); private final String name;
private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG); private String name;
Icon(Context context,String name,int color){super(context);this.name=name;paint.setColor(color);setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);}
void setName(String value){if(name.equals(value))return;name=value;invalidate();}
@Override protected void onDraw(Canvas c){
c.save();c.scale(getWidth()/24f,getHeight()/24f);paint.setStyle(Paint.Style.STROKE);paint.setStrokeWidth(1.7f);paint.setStrokeCap(Paint.Cap.ROUND);paint.setStrokeJoin(Paint.Join.ROUND);
switch(name){
@@ -118,6 +120,8 @@ final class MobileUi {
case "refresh":c.drawArc(4,4,20,20,35,310,false,paint);path(c,20,3,20,9,14,9);break;
case "close":path(c,6,6,18,18);path(c,18,6,6,18);break;
case "arrow":path(c,4,12,20,12);path(c,14,6,20,12,14,18);break;
case "keyboard_hide":
c.drawRoundRect(2,2,22,16,2,2,paint);for(int y=6;y<=9;y+=3)for(int x=6;x<=18;x+=4)c.drawPoint(x,y,paint);c.drawLine(7,13,17,13,paint);path(c,7,20,12,23,17,20);break;
case "keyboard":
c.drawRoundRect(2,5,22,19,3,3,paint);for(int y=9;y<=12;y+=3)for(int x=6;x<=18;x+=4)c.drawPoint(x,y,paint);c.drawLine(7,16,17,16,paint);break;
case "display":c.drawRoundRect(2,3,22,17,2,2,paint);path(c,12,17,12,21);path(c,7,21,17,21);break;
@@ -134,6 +138,14 @@ final class MobileUi {
case "unlock":c.drawRoundRect(5,10,19,22,2,2,paint);c.drawArc(8,2,16,15,180,150,false,paint);c.drawPoint(12,16,paint);break;
case "clipboard":c.drawRoundRect(4,4,20,22,2,2,paint);c.drawRoundRect(8,2,16,7,2,2,paint);break;
case "eye":path(c,2,12,6,7,12,5,18,7,22,12,18,17,12,19,6,17,2,12);c.drawCircle(12,12,3,paint);break;
case "eye_off":
Path hiddenEye=new Path();
hiddenEye.moveTo(10,5);hiddenEye.cubicTo(15,4,19,8,22,12);
hiddenEye.quadTo(20.4f,14.7f,18,17);
hiddenEye.moveTo(6,6);hiddenEye.quadTo(3.5f,8.5f,2,12);
hiddenEye.cubicTo(7,20,13,21,18,17);
hiddenEye.moveTo(10,10);hiddenEye.cubicTo(8,13,11,16,14,14);
c.drawPath(hiddenEye,paint);path(c,3,3,21,21);break;
default:c.drawCircle(12,12,9,paint);path(c,12,10,12,17);c.drawPoint(12,6,paint);
}c.restore();
}
@@ -30,11 +30,12 @@ final class NativeSession implements AutoCloseable {
void statistics(RemoteNetworkStatistics.Snapshot value);
default void fileTransfer(String name,double progress,boolean sending,File received) { }
default void controlFeedback(String message) { }
default void privacyChanged() { }
default void signaling(int state, String deviceId) { }
default void presence(JSONObject message) { }
default void announcement(JSONObject message) { }
default void announcementFailed(String requestId) { }
default void passwordRejected() { ended("远端密码错误"); }
default void passwordRejected() { ended(L10n.tr("远端密码错误")); }
}
// Serializes all peer creation/API calls/destruction, including across activities.
private static final ExecutorService RTC = Executors.newSingleThreadExecutor(r -> new Thread(r, "CrossDesk RTC"));
@@ -47,7 +48,9 @@ final class NativeSession implements AutoCloseable {
private String password, identity = "";
private final int port;
private long handle;
private boolean signalReady, joining, connected, started;
private boolean signalReady, joining, started;
private volatile boolean connected, supportsTextInput;
final RemotePrivacyScreen privacyScreen = new RemotePrivacyScreen();
private int signalState;
private volatile long signalGeneration;
private boolean presenceRequested;
@@ -62,12 +65,14 @@ final class NativeSession implements AutoCloseable {
private final RemoteNetworkStatistics networkStatistics=new RemoteNetworkStatistics();
private final Runnable statisticsTick=new Runnable(){public void run(){
if(closed)return;
privacyScreen.tick(android.os.SystemClock.elapsedRealtime());
listener.privacyChanged();
listener.statistics(networkStatistics.snapshot(RemoteNetworkStatistics.now()));
if(!closed)main.postDelayed(this,1000);
}};
private final Runnable timeout = () -> fail(remote.isEmpty()
? "服务器连接超时,请检查服务器地址和网络"
: "连接超时,请检查设备 ID、服务器及远端在线状态");
? L10n.tr("服务器连接超时,请检查服务器地址和网络")
: L10n.tr("连接超时,请检查设备 ID、服务器及远端在线状态"));
NativeSession(Context context, String host, int port, String remote, String password, Listener listener) {
this.context = context.getApplicationContext(); this.listener = listener;
@@ -87,10 +92,10 @@ final class NativeSession implements AutoCloseable {
File logs = new File(context.getNoBackupFilesDir(), "logs");
if (!logs.isDirectory() && !logs.mkdirs()) throw new IllegalStateException("Log directory unavailable");
handle = nCreate(this, host, port, identity, logs.getAbsolutePath(), certificates.getAbsolutePath());
if (handle == 0) { fail("原生连接初始化失败"); return; }
if (handle == 0) { fail(L10n.tr("原生连接初始化失败")); return; }
nFileDirectory(handle,new File(documents.directory,"received").getAbsolutePath());
if (surface != null && surface.isValid()) nSurface(handle, surface);
} catch (Exception error) { fail("安全存储或证书初始化失败,请重试或清除应用数据"); }
} catch (Exception error) { fail(L10n.tr("安全存储或证书初始化失败,请重试或清除应用数据")); }
});
}
/** Promote the already registered identity peer to an outgoing desktop session. */
@@ -142,7 +147,7 @@ final class NativeSession implements AutoCloseable {
if (handle == 0 || joining || remote.isEmpty() || !signalReady || identity.isEmpty()) return;
joining = true;
String base = identity.split("@", 2)[0];
if (!nConnect(handle, base, remote, password)) fail("无法发起远程连接");
if (!nConnect(handle, base, remote, password)) fail(L10n.tr("无法发起远程连接"));
password = "";
}
// Called on arbitrary native callback threads; borrowed payloads were copied by JNI.
@@ -170,10 +175,10 @@ final class NativeSession implements AutoCloseable {
publishSignaling();
tryJoin();
}
if (status == 6) fail("服务器 TLS 证书校验失败");
else if (status == 2) fail("服务器登录失败,请检查服务器地址和网络");
else if (status >= 3 && connected) fail("信令连接中断,请重新连接");
else if (status == 0 || status == 4) ui(() -> listener.status("正在连接信令服务器…"));
if (status == 6) fail(L10n.tr("服务器 TLS 证书校验失败"));
else if (status == 2) fail(L10n.tr("服务器登录失败,请检查服务器地址和网络"));
else if (status >= 3 && connected) fail(L10n.tr("信令连接中断,请重新连接"));
else if (status == 0 || status == 4) ui(() -> listener.status(L10n.tr("正在连接信令服务器…")));
} else if (type == 2) {
int status = event.getInt("status");
if (status == 1) {
@@ -182,14 +187,32 @@ final class NativeSession implements AutoCloseable {
audio = new AudioPlayer();
nControl(handle, 2, 1);
ui(() -> { listener.connected(); if(!closed){statisticsTick.run();fileTick.run();} });
} else if (status == 0 || status == 2) ui(() -> listener.status("正在建立安全连接…"));
} else if (status == 0 || status == 2) ui(() -> listener.status(L10n.tr("正在建立安全连接…")));
else if (status == 6) ui(() -> { listener.passwordRejected(); close(); });
else {
String[] reasons = {"", "", "", "连接已断开", "连接失败", "远端已关闭连接",
"远端密码错误", "找不到该设备 ID", "远端当前不可用"};
fail(status >= 0 && status < reasons.length ? reasons[status] : "连接已结束");
String[] reasons = {"", "", "", L10n.tr("连接已断开"), L10n.tr("连接失败"), L10n.tr("远端已关闭连接"),
L10n.tr("远端密码错误"), L10n.tr("找不到该设备 ID"), L10n.tr("远端当前不可用")};
fail(status >= 0 && status < reasons.length ? reasons[status] : L10n.tr("连接已结束"));
}
} else if (type == 4) ui(() -> listener.data(event));
} else if (type == 4) {
if(event.optInt("type",-1)==14){
networkStatistics.receiveRemoteEncoder(event.getJSONObject("video_codec").getString("encoder"));
return;
}
JSONObject info=event.optJSONObject("host_info");
if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false);
ui(() -> {
if(event.optInt("type",-1)==10){
privacyScreen.receive(event,android.os.SystemClock.elapsedRealtime());
listener.privacyChanged();
} else if(event.optInt("type",-1)==5){
JSONObject service=event.optJSONObject("service_status");
privacyScreen.setInteractiveStage(service==null?"":service.optString("interactive_stage",""));
listener.privacyChanged();
}
listener.data(event);
});
}
else if (type == 5) {
int width = event.getInt("width"), height = event.getInt("height");
ui(() -> listener.videoSize(width, height));
@@ -205,7 +228,7 @@ final class NativeSession implements AutoCloseable {
listener.fileTransfer(name,progress,sending,path.isEmpty()?null:new File(path));});
}
} catch (Exception error) {
if (type == 3) fail("设备身份保存失败,请检查安全存储");
if (type == 3) fail(L10n.tr("设备身份保存失败,请检查安全存储"));
}
});
}
@@ -239,11 +262,40 @@ final class NativeSession implements AutoCloseable {
void surface(Surface value) { execute(() -> { surface = value; if (handle != 0) nSurface(handle, value); }); }
void pointer(float x, float y, int flag, int wheel) { execute(() -> { if (connected) nPointer(handle, x, y, flag, wheel); }); }
void key(int code, boolean down) { execute(() -> { if (connected) nKey(handle, code, down); }); }
boolean textEdit(int deletions,String text) {
byte[] bytes=text.getBytes(StandardCharsets.UTF_8);
boolean unicode=supportsTextInput;
if(closed||!connected||deletions<0||deletions>4096||bytes.length>16*1024||text.indexOf('\0')>=0)return false;
if(!unicode)for(char ch:text.toCharArray())if(KeyMap.ascii(ch)==0)return false;
// Validate the entire edit before deleting anything. One RTC task preserves order.
execute(()->{
if(!connected)return;
for(int i=0;i<deletions;i++){nKey(handle,8,true);nKey(handle,8,false);}
if(unicode){if(bytes.length>0)nText(handle,bytes);}
else for(char ch:text.toCharArray()){
int code=KeyMap.ascii(ch);boolean shift=(code&0x100)!=0;
if(shift)nKey(handle,16,true);nKey(handle,code&255,true);nKey(handle,code&255,false);if(shift)nKey(handle,16,false);
}
});
return true;
}
void control(int type, int value) {
AudioPlayer player = audio;
if (type == 2 && player != null) player.setEnabled(value != 0);
execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} });
}
void lockPrivacy() { sendPrivacy(true); }
void togglePrivacy() { sendPrivacy(false); }
private void sendPrivacy(boolean lock) {
if(closed||!connected)return;
boolean enable=!privacyScreen.active();
long request=privacyScreen.begin(android.os.SystemClock.elapsedRealtime(),lock);
if(request<0)return;
listener.privacyChanged();
execute(()->{if(connected&&!nPrivacy(handle,lock?3:enable?1:2,true))ui(()->{
privacyScreen.failed(request);listener.privacyChanged();
});});
}
void clipboard(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 128 * 1024) return;
@@ -251,24 +303,24 @@ final class NativeSession implements AutoCloseable {
}
void secureAttention(){execute(()->{
boolean sent=connected&&nSecureAttention(handle);
ui(()->listener.controlFeedback(sent?"已发送 Ctrl+Alt+Del":"指令发送失败,请重试"));
ui(()->listener.controlFeedback(sent?L10n.tr("已发送 Ctrl+Alt+Del"):L10n.tr("指令发送失败,请重试")));
});}
void sendFile(android.net.Uri uri){
if(closed)return;
if(sendingFile){listener.controlFeedback("请等待当前文件发送完成");return;}
sendingFile=true;listener.fileTransfer("正在读取所选文件…",0,true,null);
if(sendingFile){listener.controlFeedback(L10n.tr("请等待当前文件发送完成"));return;}
sendingFile=true;listener.fileTransfer(L10n.tr("正在读取所选文件…"),0,true,null);
documents.prepare(uri,new SessionDocuments.Prepared(){
public void ready(File file,String name){execute(()->{
if(!connected||!nSendFile(handle,file.getAbsolutePath(),name.getBytes(StandardCharsets.UTF_8))){
file.delete();ui(()->{sendingFile=false;listener.fileTransfer(name,-1,true,null);});
}
});}
public void failed(){ui(()->{sendingFile=false;listener.fileTransfer("所选文件(无法读取或空间不足)",-1,true,null);});}
public void failed(){ui(()->{sendingFile=false;listener.fileTransfer(L10n.tr("所选文件(无法读取或空间不足)"),-1,true,null);});}
});
}
void saveFile(File file,android.net.Uri destination){
if(closed)return;
documents.save(file,destination,success->ui(()->listener.controlFeedback(success?"文件已保存":"文件保存失败,请重试")));
documents.save(file,destination,success->ui(()->listener.controlFeedback(success?L10n.tr("文件已保存"):L10n.tr("文件保存失败,请重试"))));
}
@Override public void close() {
if (closed) return;
@@ -290,12 +342,14 @@ final class NativeSession implements AutoCloseable {
private static native void nReady(long handle);
private static native void nPointer(long handle, float x, float y, int flag, int wheel);
private static native void nKey(long handle, int code, boolean down);
private static native void nText(long handle,byte[] text);
private static native void nControl(long handle, int type, int value);
private static native boolean nVideoSettings(long handle,int quality,int frameRate,int preference,long requestId);
private static native boolean nAnnouncementRequest(long handle,byte[] data);
private static native boolean nPresenceRequest(long handle,byte[] data);
private static native void nClipboard(long handle, byte[] data);
private static native boolean nSecureAttention(long handle);
private static native boolean nPrivacy(long handle,int flag,boolean blockLocalInput);
private static native void nFileDirectory(long handle,String directory);
private static native boolean nSendFile(long handle,String path,byte[] name);
private static native boolean nPumpFiles(long handle);
@@ -20,16 +20,16 @@ final class PrivacyConsentDialog extends Dialog {
PrivacyConsentDialog(Activity activity,MobileUi ui,PrivacyPolicyDocument policy,Runnable accepted){
super(activity);this.activity=activity;this.ui=ui;requestWindowFeature(Window.FEATURE_NO_TITLE);setCancelable(false);setCanceledOnTouchOutside(false);
LinearLayout sheet=ui.column();sheet.setBackground(ui.background(android.graphics.Color.WHITE,20));sheet.setClipToOutline(true);
TextView title=ui.text("隐私政策",17,true);title.setGravity(Gravity.CENTER);sheet.addView(title,ui.size(-1,56));ui.divider(sheet);
TextView title=ui.text(L10n.tr("隐私政策"),17,true);title.setGravity(Gravity.CENTER);sheet.addView(title,ui.size(-1,56));ui.divider(sheet);
sheet.addView(policy.view(ui),new LinearLayout.LayoutParams(-1,0,1));ui.divider(sheet);
LinearLayout actions=ui.column();actions.setPadding(ui.dp(20),ui.dp(12),ui.dp(20),ui.dp(12));sheet.addView(actions);
CheckBox hasReadPolicy=new CheckBox(activity);hasReadPolicy.setText("我已阅读并同意《隐私政策》");hasReadPolicy.setTextColor(INK);hasReadPolicy.setTextSize(14);hasReadPolicy.setMinHeight(ui.dp(44));actions.addView(hasReadPolicy,ui.size(-1,-2));ui.gap(actions,12);
TextView agree=ui.primary("同意并继续",()->{
CheckBox hasReadPolicy=new CheckBox(activity);hasReadPolicy.setText(L10n.tr("我已阅读并同意《隐私政策》"));hasReadPolicy.setTextColor(INK);hasReadPolicy.setTextSize(14);hasReadPolicy.setMinHeight(ui.dp(44));actions.addView(hasReadPolicy,ui.size(-1,-2));ui.gap(actions,12);
TextView agree=ui.primary(L10n.tr("同意并继续"),()->{
if(!hasReadPolicy.isChecked()||!policy.available)return;
dismiss();accepted.run();
});agree.setEnabled(false);agree.setAlpha(.45f);actions.addView(agree,ui.size(-1,48));
hasReadPolicy.setOnCheckedChangeListener((button,checked)->{boolean enabled=checked&&policy.available;agree.setEnabled(enabled);agree.setAlpha(enabled?1:.45f);});
ui.gap(actions,12);actions.addView(ui.action("暂不同意",SECONDARY,this::dismiss),ui.size(-1,44));
ui.gap(actions,12);actions.addView(ui.action(L10n.tr("暂不同意"),SECONDARY,this::dismiss),ui.size(-1,44));
setContentView(sheet);Window window=getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setDimAmount(.22f);}
}
@Override public void show(){
@@ -13,12 +13,14 @@ final class PrivacyPolicyDocument {
final boolean available;
final String text;
PrivacyPolicyDocument(Context context){this(load(context));}
PrivacyPolicyDocument(String document){
PrivacyPolicyDocument(Context context){this(load(context),L10n.isChinese());}
PrivacyPolicyDocument(String document){this(document,true);}
private PrivacyPolicyDocument(String document,boolean chinese){
available=document!=null&&!document.trim().isEmpty();
String value=available?document:"隐私政策暂时无法读取,请拒绝联网并重新安装应用。";
String value=available?document:L10n.tr("隐私政策暂时无法读取,请拒绝联网并重新安装应用。");
int start=value.indexOf("## 中文\n"),end=start<0?-1:value.indexOf("## English",start);
text=start>=0&&end>start?value.substring(start+"## 中文\n".length(),end).trim():value;
text=chinese&&start>=0&&end>start?value.substring(start+"## 中文\n".length(),end).trim():
!chinese&&end>=0?value.substring(end+"## English".length()).trim():value;
}
private static String load(Context context){
try{return LicenseCatalog.read(context,"PRIVACY.md");}catch(IOException error){return null;}
@@ -28,7 +30,7 @@ final class PrivacyPolicyDocument {
LinearLayout content=ui.column();content.setPadding(ui.dp(20),ui.dp(20),ui.dp(20),ui.dp(20));scroll.addView(content);
for(String paragraph:text.split("\n\n")){
paragraph=paragraph.trim();if(paragraph.isEmpty()||paragraph.equals("---"))continue;
boolean heading=paragraph.startsWith("#"),date=paragraph.startsWith("更新日期:");
boolean heading=paragraph.startsWith("#"),date=paragraph.startsWith("更新日期:")||paragraph.startsWith("Last updated:");
String value=heading?paragraph.replaceFirst("^#+\\s*",""):paragraph.replace("**","");
TextView label=ui.text(value,date?12:16,heading);label.setTextIsSelectable(true);label.setLineSpacing(ui.dp(4),1);
if(date)label.setTextColor(SECONDARY);
@@ -0,0 +1,223 @@
package cn.crossdesk.mobile;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.drawable.StateListDrawable;
import android.os.Build;
import android.os.CancellationSignal;
import android.util.SparseIntArray;
import android.view.*;
import android.view.inputmethod.InputMethodManager;
import android.widget.*;
import java.util.LinkedHashSet;
import java.util.Set;
/** Docked native IME accessory and two-page desktop keyboard share one toolbar. */
@android.annotation.SuppressLint("ViewConstructor")
final class RemoteKeyboard extends LinearLayout {
private final MobileUi ui;
private final SessionControls.Actions actions;
final RemoteKeyboardInput editor;
private final Runnable changed;
private final LinearLayout toolbar,keys;
private final TextView switchButton,lettersButton,functionsButton;
private final Set<Integer> modifiers=new LinkedHashSet<>();
private final SparseIntArray heights=new SparseIntArray();
private boolean opened,computer,functions,imeVisible,measuring,switching,targetComputer;
private int imeHeight,navigationBottom,layoutWidth,layoutHeight,revision,lastInsetsHeight=-1,lastInsetsKey;
private CancellationSignal transition;
RemoteKeyboard(Context context,MobileUi ui,SessionControls.Actions actions,Runnable changed) {
super(context);this.ui=ui;this.actions=actions;this.changed=changed;setOrientation(VERTICAL);
setVisibility(GONE);setClickable(true);
editor=new RemoteKeyboardInput(context,new RemoteKeyboardInput.Sender(){
public boolean edit(int count,String text){return actions.textEdit(count,text);}
public void key(int code){actions.key(code);}
});
FrameLayout header=new FrameLayout(context);addView(header,new LayoutParams(-1,ui.dp(44)));
header.addView(editor,new FrameLayout.LayoutParams(1,1,Gravity.BOTTOM));
toolbar=ui.row();toolbar.setGravity(Gravity.CENTER_VERTICAL);toolbar.setPadding(ui.dp(6),0,ui.dp(6),0);
header.addView(toolbar,new FrameLayout.LayoutParams(-1,-1));
switchButton=button(L10n.tr("电脑键盘"),this::switchKeyboard);switchButton.setPadding(ui.dp(8),0,ui.dp(8),0);
toolbar.addView(switchButton,new LayoutParams(-2,-1));
LinearLayout pages=ui.row();pages.setGravity(Gravity.CENTER);toolbar.addView(pages,new LayoutParams(0,-1,1));
lettersButton=button(L10n.tr("字母数字"),()->page(false));functionsButton=button(L10n.tr("符号功能"),()->page(true));
pages.addView(lettersButton,new LayoutParams(-2,ui.dp(32)));pages.addView(functionsButton,new LayoutParams(-2,ui.dp(32)));
FrameLayout hide=new FrameLayout(context);hide.setContentDescription(L10n.tr("收起键盘"));hide.setClickable(true);hide.setFocusable(true);
hide.addView(ui.icon("keyboard_hide",foreground()),new FrameLayout.LayoutParams(ui.dp(24),ui.dp(24),Gravity.CENTER));
hide.setOnClickListener(v->{feedback(v);close();});toolbar.addView(hide,new LayoutParams(ui.dp(44),ui.dp(44)));
keys=ui.column();keys.setPadding(ui.dp(3),ui.dp(4),ui.dp(3),ui.dp(4));addView(keys,new LayoutParams(-1,0,1));
render();
}
private boolean dark(){return (getResources().getConfiguration().uiMode&Configuration.UI_MODE_NIGHT_MASK)==Configuration.UI_MODE_NIGHT_YES;}
private int foreground(){return dark()?Color.WHITE:0xFF202124;}
private TextView button(String label,Runnable action){
TextView view=ui.action(label,foreground(),action);view.setTypeface(android.graphics.Typeface.DEFAULT);
view.setTextSize(13);view.setMinHeight(0);view.setPadding(ui.dp(8),0,ui.dp(8),0);
view.setOnClickListener(v->{feedback(v);action.run();});return view;
}
private void feedback(View view){view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);}
boolean isOpen(){return opened;}
boolean isComputer(){return computer;}
int bottomInset(){return opened&&!computer?imeHeight:0;}
private int sizeKey(){Configuration c=getResources().getConfiguration();return c.screenWidthDp*10000+c.screenHeightDp;}
private int keyAreaHeight(){
int height=heights.get(sizeKey(),0);
if(height==0){Configuration c=getResources().getConfiguration();height=ui.dp(c.screenWidthDp>c.screenHeightDp?200:280);}
int available=getParent() instanceof View?((View)getParent()).getMeasuredHeight()-ui.dp(44):height;
return available>0?Math.min(height,available):height;
}
int preferredHeight(){return ui.dp(44)+(computer?keyAreaHeight():0);}
void toggle(){if(opened)close();else open();}
void open(){
opened=true;computer=false;modifiers.clear();editor.start();setVisibility(VISIBLE);render();changed.run();
post(()->{if(opened&&!computer)changeIme(true,false,false);});
}
boolean close(){
if(!opened)return false;opened=false;measuring=switching=false;cancelTransition();modifiers.clear();editor.stop();
getContext().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(getWindowToken(),0);
setVisibility(GONE);changed.run();return true;
}
void resetContext(){editor.resetContext();}
private void page(boolean next){functions=next;render();}
private void switchKeyboard(){
if(!opened||switching)return;
targetComputer=!computer;switching=true;modifiers.clear();
if(targetComputer){
editor.pause();computer=true;render();changed.run();
}else editor.start();
// Lay out computer keys behind the still-visible IME before onReady,
// which runs after layout. Keep them until the native IME is ready too.
if(targetComputer&&Build.VERSION.SDK_INT>=30&&isHardwareAccelerated()){
int token=revision;
// Insets surfaces can update ahead of the app's render thread.
// Submit the replacement frame before releasing the IME surface.
getViewTreeObserver().registerFrameCommitCallback(()->post(()->{
if(opened&&switching&&targetComputer&&token==revision)changeIme(false,true,false);
}));
invalidate();
}else changeIme(!targetComputer,true,false);
}
private void finishSwitch(){
if(!switching)return;switching=false;
if(computer!=targetComputer){computer=targetComputer;render();}
changed.run();
}
void updateInsets(int height,boolean visible,int navigation){
int key=sizeKey();
if(lastInsetsHeight==height&&lastInsetsKey==key&&imeVisible==visible&&navigationBottom==navigation)return;
lastInsetsHeight=height;lastInsetsKey=key;
navigationBottom=navigation;imeHeight=Build.VERSION.SDK_INT>=30?Math.max(0,height):0;
boolean wasVisible=imeVisible;imeVisible=visible;
if(visible&&height>ui.dp(80)){
heights.put(sizeKey(),height);
if(measuring){measuring=false;post(()->{if(opened&&computer)changeIme(false,true,false);});}
}
if(switching&&visible!=targetComputer&&(!visible||height>ui.dp(80)))finishSwitch();
if(opened&&!computer&&!switching&&wasVisible&&!visible&&transition==null){close();return;}
if(opened){renderKeysIfNeeded(getMeasuredWidth());changed.run();}
}
private void cancelTransition(){revision++;if(transition!=null){CancellationSignal old=transition;transition=null;old.cancel();}}
private void changeIme(boolean show,boolean immediate,boolean measureOnly){
cancelTransition();if(!opened)return;
measuring=measureOnly;
if(Build.VERSION.SDK_INT>=30&&immediate&&getWindowInsetsController()!=null){
// Request control directly: showSoftInput first starts a competing
// system animation before we can replace the computer keyboard.
if(show)editor.start();else if(measureOnly)editor.requestFocus();
int token=revision;transition=new CancellationSignal();
getWindowInsetsController().controlWindowInsetsAnimation(WindowInsets.Type.ime(),0,null,transition,new WindowInsetsAnimationControlListener(){
public void onReady(WindowInsetsAnimationController controller,int types){
if(token!=revision||!opened){controller.finish(false);return;}
int height=controller.getShownStateInsets().bottom-navigationBottom;
if(height>ui.dp(80)){
heights.put(sizeKey(),height);measuring=false;
if(!measureOnly){imeHeight=show?height:0;finishSwitch();}
controller.finish(show&&!measureOnly);
}else if(measureOnly){
// Some IMEs report zero until their new input view is laid out.
// The first nonzero inset completes measurement and hides it.
controller.finish(true);
}else controller.finish(show);
renderKeysIfNeeded(getMeasuredWidth());changed.run();
}
public void onFinished(WindowInsetsAnimationController controller){if(token==revision)transition=null;}
public void onCancelled(WindowInsetsAnimationController controller){
if(token!=revision)return;transition=null;
if(opened)normalIme(show||measureOnly);
}
});
}else normalIme(show||measureOnly);
}
private void normalIme(boolean show){
InputMethodManager manager=getContext().getSystemService(InputMethodManager.class);
if(show){if(computer)editor.requestFocus();else editor.start();manager.showSoftInput(editor,InputMethodManager.SHOW_IMPLICIT);}
else manager.hideSoftInputFromWindow(editor.getWindowToken(),0);
}
private void render(){
setBackgroundColor(dark()?0xFF202124:0xFFE8EAED);
switchButton.setText(computer?L10n.tr("系统键盘"):L10n.tr("电脑键盘"));switchButton.setTextColor(foreground());
lettersButton.setVisibility(computer?VISIBLE:GONE);functionsButton.setVisibility(computer?VISIBLE:GONE);
for(TextView view:new TextView[]{lettersButton,functionsButton}){
boolean selected=(view==functionsButton)==functions;view.setSelected(selected);
view.setTextColor(selected?(dark()?0xFFA8C7FA:0xFF185ABC):foreground());
view.setBackground(ui.background(selected?(dark()?0xFF394457:0xFFD2E3FC):Color.TRANSPARENT,6));
}
keys.setVisibility(computer?VISIBLE:GONE);layoutWidth=layoutHeight=0;renderKeysIfNeeded(getMeasuredWidth());
}
private void renderKeysIfNeeded(int width){
if(!computer)return;int height=keyAreaHeight();
if(layoutWidth==width&&layoutHeight==height)return;layoutWidth=width;layoutHeight=height;keys.removeAllViews();
boolean landscape=getResources().getConfiguration().screenWidthDp>getResources().getConfiguration().screenHeightDp;
String[][] upper;
if(!functions)upper=new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"}};
else if(landscape)upper=new String[][]{{"`","-","=","[","]","\\",";","'",",",".","/"},{"F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"},{"Esc","Tab","Caps","Ins","Del","Home","End"}};
else upper=new String[][]{{"`","-","=","[","]","\\",";","'",",",".","/"},{"F1","F2","F3","F4","F5","F6"},{"F7","F8","F9","F10","F11","F12"},{"Esc","Tab","Caps","Ins","Del","Home","End"}};
LinearLayout upperKeys=ui.column();keys.addView(upperKeys,new LayoutParams(-1,0,1));
for(String[] row:upper)upperKeys.addView(keyRow(row),new LayoutParams(-1,0,1));
// The same bottom two rows keep their geometry on both pages.
int rowHeight=Math.max(1,(height-ui.dp(8))/5);
keys.addView(keyRow(functions?new String[]{"⇧","←","↓","↑","→","PgUp","PgDn","⌫"}:new String[]{"⇧","z","x","c","v","b","n","m","⌫"}),new LayoutParams(-1,rowHeight));
keys.addView(keyRow(new String[]{"Ctrl","Win","Alt","空格","Enter"}),new LayoutParams(-1,rowHeight));
}
private LinearLayout keyRow(String[] values){
LinearLayout line=ui.row();line.setGravity(Gravity.CENTER);
for(String value:values){
boolean shift=modifiers.contains(16);String shown=shift&&value.length()==1?shifted(value):value;
TextView key=button(L10n.tr(shown),()->key(value));key.setPadding(0,0,0,0);
key.setTextSize(value.length()>1?12:18);key.setSingleLine(true);
int modifier=modifier(value);boolean selected=modifier!=0&&modifiers.contains(modifier);key.setSelected(selected);
int color=selected?(dark()?0xFF596D8C:0xFFA8C7FA):value.length()>1||value.equals("⇧")||value.equals("⌫")?(dark()?0xFF30343B:0xFFD0D5DC):(dark()?0xFF3C4043:Color.WHITE);
StateListDrawable background=new StateListDrawable();background.addState(new int[]{android.R.attr.state_pressed},ui.background(dark()?0xFF68717D:0xFFBDC9D8,7));background.addState(new int[]{},ui.background(color,7));key.setBackground(background);
if(value.equals("⇧"))key.setContentDescription("Shift");else if(value.equals("⌫"))key.setContentDescription(L10n.tr("退格"));
// Both penultimate rows allocate exactly 1/8 to Shift and Backspace.
boolean penultimate=values[0].equals("⇧");float weight=value.equals("空格")?4:1;
if(penultimate)weight=value.equals("⇧")||value.equals("⌫")?1:(6f/(values.length-2));
boolean edge=penultimate&&(value.equals("⇧")||value.equals("⌫"));
int edgeWidth=Math.max(1,(layoutWidth-ui.dp(6))/8-ui.dp(5));
LayoutParams p=new LayoutParams(edge?edgeWidth:0,-1,edge?0:weight);p.setMargins(ui.dp(2.5f),ui.dp(3),ui.dp(2.5f),ui.dp(3));line.addView(key,p);
}
return line;
}
private static int modifier(String value){return switch(value){case "⇧"->16;case "Ctrl"->17;case "Alt"->18;case "Win"->91;default->0;};}
private static String shifted(String value){
String plain="1234567890`-=[]\\;',./",shifted="!@#$%^&*()~_+{}|:\"<>?";int index=plain.indexOf(value);
return index>=0?shifted.substring(index,index+1):value.toUpperCase(java.util.Locale.ROOT);
}
private void key(String value){
int modifier=modifier(value);if(modifier!=0){if(!modifiers.add(modifier))modifiers.remove(modifier);render();return;}
int code=switch(value){case "空格"->32;case "Enter"->13;case "⌫"->8;case "Esc"->27;case "Tab"->9;case "Caps"->20;case "Ins"->45;case "Del"->46;case "Home"->36;case "End"->35;case "PgUp"->33;case "PgDn"->34;case "←"->37;case "↑"->38;case "→"->39;case "↓"->40;default->value.startsWith("F")?111+Integer.parseInt(value.substring(1)):KeyMap.ascii(value.charAt(0))&255;};
actions.chord(code,modifiers.stream().mapToInt(Integer::intValue).toArray());
}
@Override protected void onMeasure(int widthSpec,int heightSpec){
renderKeysIfNeeded(MeasureSpec.getSize(widthSpec));super.onMeasure(widthSpec,heightSpec);
}
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){
super.onSizeChanged(width,height,oldWidth,oldHeight);
if(opened&&computer&&(width!=oldWidth||height!=oldHeight)&&heights.get(sizeKey(),0)==0)post(()->{
if(opened&&computer&&!switching&&!measuring&&transition==null&&heights.get(sizeKey(),0)==0)changeIme(false,true,true);
});
}
@Override protected void onDetachedFromWindow(){close();super.onDetachedFromWindow();}
}
@@ -0,0 +1,137 @@
package cn.crossdesk.mobile;
import android.content.Context;
import android.icu.text.BreakIterator;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputConnectionWrapper;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import java.util.ArrayList;
import java.util.List;
/** A real editor keeps IME candidates, corrections and dictation local until committed. */
@android.annotation.SuppressLint("ViewConstructor")
final class RemoteKeyboardInput extends EditText {
interface Sender {
boolean edit(int deletions, String text);
void key(int code);
}
private final Sender sender;
private final android.os.Handler main=new android.os.Handler(android.os.Looper.getMainLooper());
private String sent = "";
private boolean accepting, resetting, enterPending;
private int batches, generation;
private final Runnable synchronize = this::flush;
private void flush(){if(synchronize()&&enterPending){sender.key(13);resetContext();}}
RemoteKeyboardInput(Context context, Sender sender) {
super(context);this.sender=sender;
setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN | EditorInfo.IME_ACTION_NONE);
setCursorVisible(false);setBackground(null);setPadding(0,0,0,0);setAlpha(0);
setImportantForAutofill(IMPORTANT_FOR_AUTOFILL_NO);
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
addTextChangedListener(new TextWatcher() {
public void beforeTextChanged(CharSequence s,int start,int count,int after) { }
public void onTextChanged(CharSequence s,int start,int before,int count) { }
public void afterTextChanged(Editable value) { schedule(); }
});
}
void start() {
boolean resume=!accepting&&hasFocus();accepting=true;requestFocus();
if(resume)getContext().getSystemService(InputMethodManager.class).restartInput(this);
}
// Keep focus while replacing the IME, but reject its late edits without
// restarting it. A restart here can show it again after the controlled hide.
void pause() { accepting=false;resetContext(); }
void stop() { pause();clearFocus(); }
boolean isAcceptingInput() { return accepting; }
void resetContext() {
generation++;batches=0;main.removeCallbacks(synchronize);resetting=true;
// EditText.setText replaces the buffer and internally restarts the IME,
// even while paused. Clear in place so only the explicit restart below
// can renew the input connection.
enterPending=false;sent="";BaseInputConnection.removeComposingSpans(getText());getText().clear();setSelection(0);resetting=false;
if(accepting&&hasFocus())getContext().getSystemService(InputMethodManager.class).restartInput(this);
}
private void schedule() { if(!resetting&&accepting){main.removeCallbacks(synchronize);main.post(synchronize);} }
private boolean composing() { return BaseInputConnection.getComposingSpanStart(getText())>=0; }
private static List<String> characters(String text) {
BreakIterator iterator=BreakIterator.getCharacterInstance();iterator.setText(text);
List<String> result=new ArrayList<>();int start=iterator.first();
for(int end=iterator.next();end!=BreakIterator.DONE;start=end,end=iterator.next())result.add(text.substring(start,end));
return result;
}
private boolean synchronize() {
if(!accepting||resetting||batches>0||composing())return false;
String current=getText().toString();if(current.equals(sent))return true;
List<String> old=characters(sent),next=characters(current);int prefix=0,offset=0;
while(prefix<old.size()&&prefix<next.size()&&old.get(prefix).equals(next.get(prefix))){offset+=next.get(prefix).length();prefix++;}
if(!sender.edit(old.size()-prefix,current.substring(offset))){resetContext();return false;}
sent=current;
// Bound the correction context without changing anything on the remote desktop.
if(current.length()>2048||current.indexOf('\n')>=0)resetContext();
return true;
}
private void enter() {
if(!accepting)return;
BaseInputConnection.removeComposingSpans(getText());
enterPending=true;schedule();
}
@Override public boolean onKeyDown(int code,KeyEvent event) {
if(accepting&&code==KeyEvent.KEYCODE_ENTER){enter();return true;}
if(accepting&&code==KeyEvent.KEYCODE_DEL&&length()==0){sender.key(8);return true;}
return super.onKeyDown(code,event);
}
@Override public boolean onKeyUp(int code,KeyEvent event) {
if(accepting&&(code==KeyEvent.KEYCODE_ENTER||code==KeyEvent.KEYCODE_DEL))return true;
return super.onKeyUp(code,event);
}
@Override public InputConnection onCreateInputConnection(EditorInfo info) {
InputConnection base=super.onCreateInputConnection(info);if(base==null)return null;
int revision=++generation;batches=0;
return new InputConnectionWrapper(base,false) {
private boolean active(){return accepting&&generation==revision;}
@Override public boolean beginBatchEdit(){if(!active())return false;batches++;return super.beginBatchEdit();}
@Override public boolean endBatchEdit(){if(!active())return false;boolean result=super.endBatchEdit();batches=Math.max(0,batches-1);schedule();return result;}
@Override public boolean setComposingText(CharSequence text,int cursor){if(!active())return false;boolean result=super.setComposingText(text,cursor);schedule();return result;}
@Override public boolean setComposingRegion(int start,int end){return active()&&super.setComposingRegion(start,end);}
@Override public boolean commitText(CharSequence text,int cursor){if(!active())return false;boolean result=super.commitText(text,cursor);schedule();return result;}
@Override public boolean finishComposingText(){if(!active())return false;boolean result=super.finishComposingText();schedule();return result;}
@Override public boolean deleteSurroundingText(int before,int after){
if(!active())return false;
if(length()==0&&before>0&&after==0){for(int i=0;i<Math.min(before,2048);i++)sender.key(8);return true;}
boolean result=super.deleteSurroundingText(before,after);schedule();return result;
}
@Override public boolean deleteSurroundingTextInCodePoints(int before,int after){
if(!active())return false;
if(length()==0&&before>0&&after==0){for(int i=0;i<Math.min(before,2048);i++)sender.key(8);return true;}
boolean result=super.deleteSurroundingTextInCodePoints(before,after);schedule();return result;
}
@Override public boolean performEditorAction(int action){if(!active())return false;enter();return true;}
@Override public boolean sendKeyEvent(KeyEvent event){return active()&&super.sendKeyEvent(event);}
@Override public boolean setSelection(int start,int end){return active()&&super.setSelection(start,end);}
@Override public void closeConnection(){if(active())super.closeConnection();}
@android.annotation.SuppressLint("NewApi") // Framework calls these overloads only on API 33+.
@Override public boolean commitText(CharSequence text,int cursor,android.view.inputmethod.TextAttribute attributes){
if(!active())return false;boolean result=super.commitText(text,cursor,attributes);schedule();return result;
}
@android.annotation.SuppressLint("NewApi") // Framework calls these overloads only on API 33+.
@Override public boolean setComposingText(CharSequence text,int cursor,android.view.inputmethod.TextAttribute attributes){
if(!active())return false;boolean result=super.setComposingText(text,cursor,attributes);schedule();return result;
}
@android.annotation.SuppressLint("NewApi") // Framework calls these overloads only on API 33+.
@Override public boolean setComposingRegion(int start,int end,android.view.inputmethod.TextAttribute attributes){return active()&&super.setComposingRegion(start,end,attributes);}
@android.annotation.SuppressLint("NewApi") // Framework calls this overload only on API 34+.
@Override public boolean replaceText(int start,int end,CharSequence text,int cursor,android.view.inputmethod.TextAttribute attributes){
if(!active())return false;boolean result=super.replaceText(start,end,text,cursor,attributes);schedule();return result;
}
};
}
}
@@ -3,6 +3,8 @@ package cn.crossdesk.mobile;
/** Shared button ownership and cursor position for the video surface and mouse bar. */
final class RemoteMouseInput {
interface Sender { void send(float x, float y, int flag, int wheel); }
interface RelativeMotion { void move(float dx, float dy); }
RelativeMotion relativeMotion;
private final Sender sender;
private float x = .5f, y = .5f;
private boolean gestureLeft, virtualLeft, virtualRight;
@@ -19,7 +21,8 @@ final class RemoteMouseInput {
}
void drag(float dx, float dy) {
if (!hasVirtualButton() || viewportWidth <= 0 || viewportHeight <= 0) return;
move(x + dx / viewportWidth, y + dy / viewportHeight);
if (relativeMotion != null) relativeMotion.move(dx, dy);
else move(x + dx / viewportWidth, y + dy / viewportHeight);
}
void gestureLeft(boolean down) {
if (down && hasVirtualButton()) return;
@@ -12,31 +12,32 @@ final class RemoteNetworkStatistics {
}
static final class Report {
final Traffic[] traffic=new Traffic[4];final int mode;final boolean srtp;final double rtt;
final String decoder;
Report(JSONObject json){
String[] keys={"video","audio","data","total"};
for(int i=0;i<keys.length;i++){JSONObject item=json.optJSONObject(keys[i]);traffic[i]=item==null?null:new Traffic(item);}
mode=json.optInt("mode",2);srtp=json.optBoolean("srtp");rtt=json.optDouble("rtt",Double.NaN);
decoder=json.optString("decoder","");
}
}
static final class Snapshot {
final Report report;final int fps,width,height;final double latency,rtt;
Snapshot(Report report,int fps,int width,int height,double latency,double rtt){this.report=report;this.fps=fps;this.width=width;this.height=height;this.latency=latency;this.rtt=rtt;}
String mode(){return report==null?"—":report.mode==0?"P2P 直连":report.mode==1?"TURN 中继":"—";}
final String remoteEncoder,localDecoder;
Snapshot(Report report,int fps,int width,int height,double latency,double rtt,String remoteEncoder,String localDecoder){this.report=report;this.fps=fps;this.width=width;this.height=height;this.latency=latency;this.rtt=rtt;this.remoteEncoder=remoteEncoder;this.localDecoder=localDecoder;}
String mode(){return report==null?"—":report.mode==0?L10n.tr("P2P 直连"):report.mode==1?L10n.tr("TURN 中继"):"—";}
String resolution(){return width>0&&height>0?width+" × "+height:"—";}
String summary(){
String value=mode()+(width>0&&height>0?" · "+width+"×"+height:"")+" · "+bitrate(report==null||report.traffic[3]==null?-1:report.traffic[3].inbound);
if(report!=null&&report.traffic[0]!=null&&report.traffic[0].loss>0)value+=" · 丢包 "+loss(report.traffic[0].loss);return value;
}
}
private static final class Frame {
final double time,latency;Frame(double time,double latency){this.time=time;this.latency=latency;}
}
private Report report;
private String remoteEncoder="";
private double reportTime,rttTime,averageRtt=Double.NaN;
private long lastFrameId;
private int width,height;
private final ArrayDeque<Frame> frames=new ArrayDeque<>();
static double now(){return System.nanoTime()/1_000_000_000.0;}
synchronized void receiveRemoteEncoder(String encoder){remoteEncoder=encoder;}
synchronized void receive(Report value,double now){
report=value;reportTime=now;
if(!Double.isFinite(value.rtt)||value.rtt<0||value.rtt>2000)return;
@@ -53,7 +54,7 @@ final class RemoteNetworkStatistics {
synchronized Snapshot snapshot(double now){
prune(now);double total=0;int count=0;
for(Frame frame:frames)if(Double.isFinite(frame.latency)){total+=frame.latency;count++;}
return new Snapshot(now-reportTime<3?report:null,frames.size(),width,height,count==0?Double.NaN:total/count,now-rttTime<3?averageRtt:Double.NaN);
return new Snapshot(now-reportTime<3?report:null,frames.size(),width,height,count==0?Double.NaN:total/count,now-rttTime<3?averageRtt:Double.NaN,remoteEncoder,report==null?"":report.decoder);
}
private void prune(double now){while(!frames.isEmpty()&&now-frames.peekFirst().time>=1)frames.removeFirst();}
static String bitrate(long value){return value<0?"—":value>=1_000_000?String.format(Locale.ROOT,"%.1f Mbps",value/1_000_000.0):value>=1000?String.format(Locale.ROOT,"%.0f Kbps",value/1000.0):value+" bps";}
@@ -0,0 +1,30 @@
package cn.crossdesk.mobile;
import android.app.Activity;
import android.content.pm.ActivityInfo;
/** Applies the selected orientation policy for the whole remote session. */
final class RemoteOrientation {
private final Activity activity;
private boolean autoLandscape;
RemoteOrientation(Activity activity) {
this.activity = activity;
}
void begin(boolean autoLandscape, boolean fullScreenPhone) {
this.autoLandscape = autoLandscape;
update(fullScreenPhone);
}
boolean autoLandscape() { return autoLandscape; }
void end() { autoLandscape = false; }
void update(boolean fullScreenPhone) {
int orientation = autoLandscape && fullScreenPhone
? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
: ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
if (activity.getRequestedOrientation() != orientation) activity.setRequestedOrientation(orientation);
}
}
@@ -127,7 +127,7 @@ final class RemotePreviewStore {
new AtomicFile(new File(directory,key+".jpg")).delete();
synchronized(this){
boolean saved=index.edit().putStringSet("files",new HashSet<>(files)).commit();
if(!saved||new File(directory,key+".jpg").exists())cleanupError="部分预览图未能清除,请重试。";
if(!saved||new File(directory,key+".jpg").exists())cleanupError=L10n.tr("部分预览图未能清除,请重试。");
}
changed();
});changed();
@@ -139,7 +139,7 @@ final class RemotePreviewStore {
try{deleteTree(directory);}catch(IOException error){success=false;}
synchronized(this){
if(epoch!=generation)return;
cleaning=false;cleanupError=success?"":"部分预览图未能清除,请重试。";
cleaning=false;cleanupError=success?"":L10n.tr("部分预览图未能清除,请重试。");
}
changed();
});
@@ -0,0 +1,97 @@
package cn.crossdesk.mobile;
import org.json.JSONObject;
/** Host-confirmed privacy state; all access belongs to the main thread. */
final class RemotePrivacyScreen {
boolean desktopAvailable = true;
boolean received, supported, inputBlockSupported, overlay, inputBlocked, remotePaused, pending;
boolean unlockNotice;
long localUnlockRevision;
int state;
long revision, requestRevision, requestId, receivedAt, requestedAt;
String reason = "", failure = "";
void setInteractiveStage(String stage) {
desktopAvailable = !"lock-screen".equals(stage) &&
!"credential-ui".equals(stage) && !"secure-desktop".equals(stage);
}
void receive(JSONObject message, long now) {
try {
if (message.getInt("type") != 10) return;
JSONObject value = message.getJSONObject("privacy_status");
int next = value.getInt("state");
long rev = value.getLong("revision");
if (next < 0 || next > 5 || rev < 0 || rev > 0xffffffffL ||
received && (int)(rev - revision) < 0) return;
boolean support = value.getBoolean("supported"), cover = value.getBoolean("overlay_active");
boolean paused = value.getBoolean("remote_paused");
if (next == 3 && (!support || !cover || paused)) return;
boolean blockSupport = value.getBoolean("input_block_supported"), blocked = value.getBoolean("input_blocked");
String detail = value.getString("reason");
long unlock = value.has("local_unlock_revision") ? value.getLong("local_unlock_revision") : 0;
if (unlock < 0 || unlock > 0xffffffffL) return;
if (received && (int)(unlock - localUnlockRevision) > 0) unlockNotice = true;
if (next == 3) unlockNotice = false;
localUnlockRevision = unlock;
state = next; revision = rev; supported = support; overlay = cover; remotePaused = paused;
inputBlockSupported = blockSupport; inputBlocked = blocked; reason = detail;
received = true; receivedAt = now; failure = "";
if (pending && (int)(revision - requestRevision) > 0 && state != 2 && state != 4) pending = false;
} catch (org.json.JSONException ignored) { }
}
boolean active() { return overlay || remotePaused || state == 2 || state == 3 || state == 4; }
boolean fresh(long now) { return received && now - receivedAt <= 5000; }
boolean enabled(long now) { return fresh(now) && state == 3 && overlay && !remotePaused; }
String buttonTitle(long now) {
if (enabled(now)) return L10n.tr("隐私屏已开启");
if (fresh(now) && supported && state == 1 && !active()) return L10n.tr("隐私屏已关闭");
return L10n.tr("隐私屏");
}
String feedback(long now) {
if (!desktopAvailable) return "";
if (failure.isEmpty() && (pending || fresh(now) && (state == 2 || state == 4))) return "";
if (failure.isEmpty() && !pending && fresh(now) && supported &&
(enabled(now) || state == 1 && !active())) return "";
String detail = detail(now);
return status(now) + (detail.isEmpty() ? "" : ":" + detail);
}
boolean canToggle(long now) {
return desktopAvailable && !pending && fresh(now) && state != 4 && (active() || supported && state == 1);
}
boolean canLock(long now) {
return desktopAvailable && !pending && fresh(now) && supported;
}
long begin(long now) { return begin(now, false); }
long begin(long now, boolean lock) {
tick(now);
if (!(lock ? canLock(now) : canToggle(now))) return -1;
pending = true; requestedAt = now; requestRevision = revision; failure = "";
return ++requestId;
}
void failed(long id) {
if (pending && requestId == id) { pending = false; failure = L10n.tr("指令发送失败,请重试"); }
}
void tick(long now) {
if (pending && now - requestedAt >= 15000) {
pending = false; failure = L10n.tr("请求超时,尚未确认电脑的隐私屏状态");
}
}
String status(long now) {
if (!failure.isEmpty()) return failure;
if (!received) return L10n.tr("等待电脑返回隐私屏状态;旧版电脑端可能不支持");
if (!fresh(now)) return L10n.tr("隐私屏状态已过期,等待电脑确认");
if (state == 2) return L10n.tr("正在开启隐私屏…");
if (state == 4) return L10n.tr("正在关闭隐私屏…");
if (pending) return L10n.tr("指令已发送,等待电脑确认…");
if (state == 5) return L10n.tr("隐私屏异常");
if (!supported || state == 0) return L10n.tr("隐私屏暂不可用");
if (state == 3 && overlay && !remotePaused)
return L10n.tr("隐私屏已开启");
return state == 1 && !active() ? L10n.tr("隐私屏已关闭") : L10n.tr("隐私屏状态待确认");
}
String detail(long now) {
return fresh(now) && (state == 0 || state == 5 || !supported) ? reason : "";
}
}
@@ -51,7 +51,7 @@ public final class RemoteSessionService extends Service {
@Override public void onCreate() {
super.onCreate(); running = this;
getSystemService(NotificationManager.class).createNotificationChannel(
new NotificationChannel(CHANNEL, "远程连接", NotificationManager.IMPORTANCE_LOW));
new NotificationChannel(CHANNEL, L10n.tr("远程连接"), NotificationManager.IMPORTANCE_LOW));
wakeLock = getSystemService(PowerManager.class).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "CrossDesk:RemoteSession");
wakeLock.setReferenceCounted(false);
}
@@ -60,7 +60,7 @@ public final class RemoteSessionService extends Service {
if (value == null || value.ended || value.session.isClosed()) { stopSelf(startId); return START_NOT_STICKY; }
if (intent == null || !value.token.equals(intent.getStringExtra("session"))) return START_NOT_STICKY;
owned = value;
if (STOP.equals(intent.getAction())) { value.finish("已从通知断开远程连接"); return START_NOT_STICKY; }
if (STOP.equals(intent.getAction())) { value.finish(L10n.tr("已从通知断开远程连接")); return START_NOT_STICKY; }
try {
Notification notification = notification();
if (Build.VERSION.SDK_INT >= 34) startForeground(NOTIFICATION, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
@@ -68,7 +68,7 @@ public final class RemoteSessionService extends Service {
update();
} catch (RuntimeException error) {
Log.w("CrossDesk", "Background session foreground promotion failed", error);
value.finish("后台保活未能启动,请保持应用在前台后重新连接");
value.finish(L10n.tr("后台保活未能启动,请保持应用在前台后重新连接"));
}
return START_NOT_STICKY;
}
@@ -80,12 +80,12 @@ public final class RemoteSessionService extends Service {
.setData(android.net.Uri.parse("crossdesk-session:" + owned.token)).putExtra("session", owned.token);
PendingIntent stop = PendingIntent.getService(this, 1, stopIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
return new Notification.Builder(this, CHANNEL).setSmallIcon(R.drawable.ic_remote_session)
.setContentTitle("CrossDesk 远程连接")
.setContentText(owned.background ? "后台保持连接,点按返回远程桌面" : "已启用后台保活,切换应用可继续保持连接")
.setContentTitle(L10n.tr("CrossDesk 远程连接"))
.setContentText(owned.background ? L10n.tr("后台保持连接,点按返回远程桌面") : L10n.tr("已启用后台保活,切换应用可继续保持连接"))
.setContentIntent(open).setOngoing(true).setOnlyAlertOnce(true).setShowWhen(false)
.setCategory(Notification.CATEGORY_SERVICE).setVisibility(Notification.VISIBILITY_PRIVATE)
.addAction(new Notification.Action.Builder(null, "返回", open).build())
.addAction(new Notification.Action.Builder(null, "断开", stop).build()).build();
.addAction(new Notification.Action.Builder(null, L10n.tr("返回"), open).build())
.addAction(new Notification.Action.Builder(null, L10n.tr("断开"), stop).build()).build();
}
@android.annotation.SuppressLint("WakelockTimeout") // Released on foreground return, disconnect, task removal and service destruction.
private void update() {
@@ -96,14 +96,14 @@ public final class RemoteSessionService extends Service {
}
private void releaseWakeLock() { if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); }
@Override public void onTaskRemoved(Intent rootIntent) {
if (current == owned && owned != null) owned.finish("远程任务已关闭");
if (current == owned && owned != null) owned.finish(L10n.tr("远程任务已关闭"));
stopSelf();
}
@Override public void onDestroy() {
if (running == this) running = null;
releaseWakeLock(); stopForeground(STOP_FOREGROUND_REMOVE);
BackgroundSession value = owned;
if (current == value && value != null) { current = null; value.finish("后台连接服务已停止,请重新连接"); }
if (current == value && value != null) { current = null; value.finish(L10n.tr("后台连接服务已停止,请重新连接")); }
super.onDestroy();
}
}
@@ -4,11 +4,11 @@ import org.json.JSONObject;
/** Same request/acknowledgement and rollback rules as the iOS controller. */
final class RemoteVideoSettings {
static final String[] PREFERENCES={"帧率优先","画质优先","平衡"};
static final String[] DETAILS={
"优先保持画面流畅,带宽或编码压力较大时会更积极地降低分辨率。",
"始终保持较高分辨率,带宽不足时通过降低帧率来保证画面清晰度。",
"兼顾清晰度和流畅度,只在持续压力下逐步降低分辨率。"};
static String[] preferences(){return new String[]{L10n.tr("帧率优先"),L10n.tr("画质优先"),L10n.tr("平衡")};}
static String[] details(){return new String[]{
L10n.tr("优先保持画面流畅,带宽或编码压力较大时会更积极地降低分辨率。"),
L10n.tr("始终保持较高分辨率,带宽不足时通过降低帧率来保证画面清晰度。"),
L10n.tr("兼顾清晰度和流畅度,只在持续压力下逐步降低分辨率。")};}
static final class Values {
final int quality,frameRate,preference;
Values(int quality,int frameRate,int preference){this.quality=quality;this.frameRate=frameRate;this.preference=preference;}
@@ -54,5 +54,5 @@ final class RemoteVideoSettings {
}catch(org.json.JSONException ignored){ }
}
void expire(long expiredId){if(pending&&expiredId==requestId){selection=applied;pending=false;failed=true;}}
String feedback(){return !supported?"连接建立后可调整,需被控端支持。":failed?"调整失败,请重试。":"";}
String feedback(){return !supported?L10n.tr("连接建立后可调整,需被控端支持。"):failed?L10n.tr("调整失败,请重试。"):"";}
}
@@ -24,6 +24,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private final Cursor cursor;
private final NativeSession session;
final RemoteMouseInput mouseInput;
Runnable remoteFocusChanged;
private final GestureDetector gestures;
private final ScaleGestureDetector scales;
private final RemoteViewport viewport = new RemoteViewport();
@@ -50,6 +51,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
RemoteVideoView(Context context, NativeSession session) {
super(context); this.session = session;
mouseInput = new RemoteMouseInput(this::sendMouse);
mouseInput.relativeMotion = this::moveRelative;
setBackgroundColor(Color.BLACK);
video = new SurfaceView(context); video.getHolder().addCallback(this);
addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER));
@@ -76,7 +78,8 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override public boolean onScaleBegin(ScaleGestureDetector detector) {
if (mouseInput.hasVirtualButton()) return false;
if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false;
// Pinches can start anywhere on the touch surface, including letterboxing.
if (viewport.width() <= 0 || viewport.height() <= 0) return false;
viewportGesture = true;
viewportPanActive = true;
return true;
@@ -101,7 +104,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
}
return true;
});
setContentDescription("远程桌面:轻触点击,拖动移动,长按右键,双指捏合缩放,放大后双指拖动画面,原始比例下双指上下滑动滚动");
setContentDescription(L10n.tr("远程桌面:轻触点击,拖动移动,长按右键,双指捏合缩放,放大后双指拖动画面,原始比例下双指上下滑动滚动"));
}
void videoSize(int width, int height) {
if (width <= 0 || height <= 0) return;
@@ -141,18 +144,26 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
}
private void sendMouse(float x, float y, int flag, int wheel) {
this.x = x; this.y = y;
if((flag==1||flag==3||flag==5)&&remoteFocusChanged!=null)remoteFocusChanged.run();
session.pointer(x,y,flag,wheel);
cursor.invalidate();
}
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); }
private void moveRelative(float dx, float dy) {
if (pictureInPicture || viewport.width() <= 0 || viewport.height() <= 0) return;
viewport.panForPointer(y, dy);
applyViewport();
mouseInput.move(x + dx / viewport.width(), y + dy / viewport.height());
}
private boolean point(MotionEvent e) {
boolean touchpad = relative && e.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE;
float px=e.getX(),py=e.getY();
if (!touchpad && !viewport.contains(px,py)) return false;
if(touchpad){
if(e.getActionMasked()==MotionEvent.ACTION_MOVE){
x=Math.max(0,Math.min(1,x+(px-lastX)/Math.max(1,viewport.width())));
y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,viewport.height())));
moveRelative(px-lastX,py-lastY);
lastX=px;lastY=py;
return true;
}
}else{
x=viewport.normalizedX(px);
@@ -233,7 +244,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private final Path arrow = new Path();
private final float arrowWidth = 12, arrowHeight = 15;
Cursor(Context context) {
super(context); setClickable(true); setContentDescription("远程桌面触控区域");
super(context); setClickable(true); setContentDescription(L10n.tr("远程桌面触控区域"));
// Match the iOS RemoteCursorArrowShape in density-independent units.
arrow.moveTo(arrowWidth*.08f,arrowHeight*.04f);
arrow.lineTo(arrowWidth*.08f,arrowHeight*.78f);
@@ -3,11 +3,14 @@ package cn.crossdesk.mobile;
/** Geometry shared by the video Surface, cursor and remote pointer input. */
final class RemoteViewport {
private static final float MAXIMUM_SCALE = 10;
private int baseWidth, baseHeight, baseLeft, baseTop;
private static final float MINIMUM_VISIBLE_HEIGHT_FRACTION = .2f;
private int baseWidth, baseHeight, baseLeft, baseTop, containerHeight;
private boolean edgePanned;
private float scale = 1, offsetX, offsetY;
void fit(int width, int height, int videoWidth, int videoHeight) {
if (width <= 0 || height <= 0 || videoWidth <= 0 || videoHeight <= 0) return;
containerHeight = height;
double fitScale = Math.min((double) width / videoWidth, (double) height / videoHeight);
baseWidth = Math.max(1, (int) (videoWidth * fitScale));
baseHeight = Math.max(1, (int) (videoHeight * fitScale));
@@ -54,18 +57,46 @@ final class RemoteViewport {
constrainOffset();
}
void finishGesture() {
if (!isZoomed()) reset();
void panForPointer(float y, float dy) {
if (baseHeight == 0 || !Float.isFinite(dy)) return;
float overflowY = pointerOverflow(top() + y * height(), dy,
Math.max(0, top()), Math.min(containerHeight, top() + height()));
if (overflowY == 0) return;
edgePanned = true;
panBy(0, -overflowY);
}
void reset() { scale = 1; offsetX = offsetY = 0; }
// Only the outward portion of this movement pans; an offscreen cursor must
// not make a tiny movement jump the viewport by its entire hidden distance.
private static float pointerOverflow(float position, float delta, float low, float high) {
if (delta > 0) return Math.min(delta, Math.max(0, position + delta - high));
if (delta < 0) return Math.max(delta, Math.min(0, position + delta - low));
return 0;
}
void finishGesture() {
if (!isZoomed()) {
scale = 1;
constrainOffset();
}
}
void reset() { scale = 1; offsetX = offsetY = 0; edgePanned = false; }
private void constrainOffset() {
// Keep the original fitted area covered, as on iOS, even with letterboxing.
float maximumX = baseWidth * (scale - 1) / 2;
float maximumY = baseHeight * (scale - 1) / 2;
float minimumY = -baseHeight * (scale - 1) / 2;
float maximumY = -minimumY;
if (edgePanned) {
// Reserve 20% of the viewport height regardless of zoom. If the
// whole image is shorter than that, keep its full height visible.
float visibleHeight = Math.min(height(), containerHeight * MINIMUM_VISIBLE_HEIGHT_FRACTION);
float centerY = baseTop + baseHeight / 2f;
minimumY = visibleHeight - centerY - height() / 2;
maximumY = containerHeight - visibleHeight - centerY + height() / 2;
}
offsetX = clamp(offsetX, -maximumX, maximumX);
offsetY = clamp(offsetY, -maximumY, maximumY);
offsetY = clamp(offsetY, minimumY, maximumY);
}
private static float clamp(float value, float minimum, float maximum) {
@@ -31,7 +31,7 @@ final class SegmentedControl extends LinearLayout {
setGravity(Gravity.CENTER_VERTICAL);setPadding(inset,inset,inset,inset);setBackground(ui.background(0xFFE9E9EF,MobileUi.SEGMENT_HEIGHT/2));paint.setColor(Color.WHITE);
for(int i=0;i<titles.length;i++){
final int index=i;TextView choice=ui.action(titles[i],MobileUi.INK,()->select(index));
choice.setTextSize(13);choice.setMinHeight(height);choice.setSelected(i==selected);choice.setEnabled(enabled);choice.setAlpha(enabled?1:.45f);
choice.setTextSize(13);choice.setSingleLine();choice.setAutoSizeTextTypeUniformWithConfiguration(9,13,1,android.util.TypedValue.COMPLEX_UNIT_SP);choice.setMinHeight(height);choice.setSelected(i==selected);choice.setEnabled(enabled);choice.setAlpha(enabled?1:.45f);
addView(choice,new LayoutParams(0,height,1));
}
}
@@ -6,29 +6,41 @@ import android.view.*;
import android.widget.*;
import static cn.crossdesk.mobile.MobileUi.*;
/** Safe-area overlay: a draggable 54 dp orb, an adaptive menu and a floating keyboard. */
/** Safe-area overlay: a draggable 54 dp orb, an adaptive menu and a docked keyboard. */
@android.annotation.SuppressLint("ViewConstructor")
final class SessionControls extends FrameLayout {
interface Actions {
void disconnect(); void display(); void clipboard(); void key(int code);
void chord(int code,int[] modifiers); void shortcut(int code); void text(String value); void mute(); void mouse();
default boolean textEdit(int deletions,String value){return false;}
void videoSettings(int field,int value);
void sendFile(); void saveFile(); void secureAttention();
default void togglePrivacy() { }
}
private final MobileUi ui;
private final Actions actions;
private final View orb;
private final LinearLayout waiting;
private View shield;
private LinearLayout menu,keyboard;
private LinearLayout menu;
private final RemoteKeyboard keyboard;
private VirtualMouseBar mouseBar;
private FrameLayout mouseHost;
private boolean mouseVisible,pictureInPicture;
private ScrollView keyboardKeys;
private ScrollView menuScroll;
private int keyboardPreferredHeight;
private int menuColumns=3;
private TextView networkLabel,videoFeedback;
private final ViewTreeObserver.OnGlobalLayoutListener legacyKeyboardLayout=this::legacyKeyboardLayout;
private void legacyKeyboardLayout(){
if(android.os.Build.VERSION.SDK_INT>=30)return;
android.graphics.Rect visible=new android.graphics.Rect();getWindowVisibleDisplayFrame(visible);
int height=Math.max(0,getRootView().getHeight()-visible.bottom);
keyboardInsets(height,height>ui.dp(80),0);
}
private TextView videoFeedback;
private RemotePrivacyScreen privacy=new RemotePrivacyScreen();
private View privacyButton;
private MobileUi.Icon privacyIcon;
private TextView privacyFeedback,privacyCaption;
private TextView transferLabel,saveFileButton;
private String transferStatus="";
private boolean hasReceivedFile;
@@ -40,20 +52,27 @@ final class SessionControls extends FrameLayout {
private LinearLayout[] trafficRows;
private TextView[] networkDetails;
private View encryptionLock,encryptionOpen;
private boolean muted,relative=true,shift,computerKeyboard,orbPositioned;
private final java.util.Set<Integer> modifiers=new java.util.LinkedHashSet<>();
private boolean muted,relative=true,orbPositioned,secureAttentionVisible;
SessionControls(Context context,MobileUi ui,Actions actions){
super(context);this.ui=ui;this.actions=actions;
keyboard=new RemoteKeyboard(context,ui,actions,()->{requestLayout();positionMouse();});
addView(keyboard,new LayoutParams(-1,ui.dp(44),Gravity.BOTTOM));
// This container must remain transparent to remote touch outside its controls.
waiting=ui.column();waiting.setMinimumWidth(ui.dp(200));waiting.setGravity(Gravity.CENTER);waiting.setPadding(ui.dp(20),ui.dp(20),ui.dp(20),ui.dp(20));waiting.setBackground(ui.background(0xB0202024,14));
ProgressBar progress=new ProgressBar(context);progress.setIndeterminateTintList(android.content.res.ColorStateList.valueOf(Color.WHITE));waiting.addView(progress,ui.size(28,28));ui.gap(waiting,12);TextView message=ui.text("等待远端画面…",14,false);message.setTextColor(Color.WHITE);message.setGravity(Gravity.CENTER);waiting.addView(message);addView(waiting,new LayoutParams(-2,-2,Gravity.CENTER));
orb=ui.iconButton("display","展开远程控制菜单",()->{if(menu==null)openMenu("controls");else closeMenu();});
ProgressBar progress=new ProgressBar(context);progress.setIndeterminateTintList(android.content.res.ColorStateList.valueOf(Color.WHITE));waiting.addView(progress,ui.size(28,28));ui.gap(waiting,12);TextView message=ui.text(L10n.tr("等待远端画面…"),14,false);message.setTextColor(Color.WHITE);message.setGravity(Gravity.CENTER);waiting.addView(message);addView(waiting,new LayoutParams(-2,-2,Gravity.CENTER));
orb=ui.iconButton("display",L10n.tr("展开远程控制菜单"),()->{if(menu==null)openMenu("controls");else closeMenu();});
ImageView brand=new ImageView(context);brand.setImageResource(R.drawable.mobile_brand);brand.setScaleType(ImageView.ScaleType.CENTER_CROP);brand.setBackground(ui.background(Color.WHITE,23));brand.setClipToOutline(true);brand.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
((FrameLayout)orb).removeAllViews();((FrameLayout)orb).addView(brand,new LayoutParams(ui.dp(46),ui.dp(46),Gravity.CENTER));
orb.setBackground(ui.background(0xFFF5F5F7,27));orb.setElevation(ui.dp(7));addView(orb,new LayoutParams(ui.dp(54),ui.dp(54)));
drag(orb,orb,()->{if(menu==null)openMenu("controls");else closeMenu();});
}
void setRelative(boolean value){relative=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
void setHostPlatform(String platform){
boolean visible="windows".equals(platform);
if(secureAttentionVisible==visible)return;
secureAttentionVisible=visible;
if(menu!=null&&menuPage.equals("controls"))openMenu("controls");
}
void attachMouseBar(FrameLayout host, VirtualMouseBar bar) {
mouseHost=host;mouseBar=bar;
// A direct, tightly bounded sibling of the video is essential: otherwise
@@ -65,7 +84,7 @@ final class SessionControls extends FrameLayout {
void cancelMouseInput(){if(mouseBar!=null)mouseBar.cancelInput();}
void setPictureInPicture(boolean value){
pictureInPicture=value;
if(value){cancelMouseInput();closeMenu();closeKeyboard();modifiers.clear();}
if(value){cancelMouseInput();closeMenu();closeKeyboard();}
setVisibility(value?GONE:VISIBLE);positionMouse();
}
private void toggleMouse(){cancelMouseInput();mouseVisible=!mouseVisible;closeMenu();positionMouse();}
@@ -80,7 +99,7 @@ final class SessionControls extends FrameLayout {
// When the orb is near the middle of a narrow phone, use the adjacent
// row rather than letting the two controls overlap.
if(x+width>getWidth()-margin){x=bound(orb.getX(),margin,getWidth()-width-margin);y=orb.getY()+orb.getHeight()+gap;if(y+height>getHeight()-margin)y=orb.getY()-gap-height;}
if(keyboard!=null&&x<keyboard.getX()+keyboard.getWidth()&&x+width>keyboard.getX()&&y<keyboard.getY()+keyboard.getHeight()&&y+height>keyboard.getY()){
if(keyboard.isOpen()&&x<keyboard.getX()+keyboard.getWidth()&&x+width>keyboard.getX()&&y<keyboard.getY()+keyboard.getHeight()&&y+height>keyboard.getY()){
y=keyboard.getY()-gap-height;if(y<margin)y=keyboard.getY()+keyboard.getHeight()+gap;
}
mouseBar.setX(location[0]-hostLocation[0]+bound(x,margin,getWidth()-width-margin));
@@ -89,23 +108,24 @@ final class SessionControls extends FrameLayout {
}
void setMuted(boolean value){muted=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
void videoSize(int width,int height){waiting.setVisibility(GONE);positionMouse();}
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt,network.remoteEncoder,network.localDecoder));}
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
void privacyScreen(RemotePrivacyScreen value){privacy=value;renderPrivacy();}
void fileStatus(String status,boolean received){transferStatus=status;hasReceivedFile=received;renderFileStatus();}
private void renderFileStatus(){
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}videoFeedback=transferLabel=saveFileButton=null;privacyFeedback=privacyCaption=null;privacyButton=null;privacyIcon=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription(L10n.tr("展开远程控制菜单"));positionMouse();}
private void openMenu(String page){
cancelMouseInput();
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
menu=ui.column();menu.setPadding(ui.dp(12),ui.dp(12),ui.dp(12),ui.dp(12));ui.card(menu,18);menu.setElevation(ui.dp(12));
LinearLayout header=ui.row();
if(!page.equals("controls"))header.addView(ui.iconButton("back","返回控制栏",()->openMenu("controls")),ui.size(28,32));
LinearLayout titles=ui.column();TextView title=ui.text(page.equals("controls")?"● 已连接":page.equals("video")?"画面设置":"网络状态",14,true);titles.addView(title);
if(page.equals("controls")){ui.gap(titles,3);networkLabel=ui.text(network.summary(),10,false);networkLabel.setFontFeatureSettings("tnum");networkLabel.setTextColor(SECONDARY);titles.addView(networkLabel);}header.addView(titles,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close","关闭控制栏",this::closeMenu),ui.size(30,32));menu.addView(header);ui.gap(menu,9);
if(!page.equals("controls"))header.addView(ui.iconButton("back",L10n.tr("返回控制栏"),()->openMenu("controls")),ui.size(28,32));
TextView title=ui.text(page.equals("controls")?L10n.tr("● 已连接"):page.equals("video")?L10n.tr("画面设置"):L10n.tr("网络状态"),14,true);
header.addView(title,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close",L10n.tr("关闭控制栏"),this::closeMenu),ui.size(30,32));menu.addView(header);ui.gap(menu,9);
if(page.equals("video")){videoFeedback=ui.text("",12,false);videoFeedback.setTextColor(SECONDARY);videoFeedback.setPadding(0,0,0,ui.dp(9));menu.addView(videoFeedback);}
ScrollView scroll=new ScrollView(getContext());menuScroll=scroll;menu.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout body=ui.column();scroll.addView(body);
if(page.equals("network")){
@@ -113,26 +133,48 @@ final class SessionControls extends FrameLayout {
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
}
if(page.equals("controls")){
String[] labels={"键盘",relative?"相对鼠标":"绝对鼠标",mouseVisible?"收起鼠标":"虚拟鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::mouse,this::toggleMouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
String[] labels={L10n.tr("键盘"),mouseVisible?L10n.tr("收起鼠标"):L10n.tr("虚拟鼠标"),relative?L10n.tr("相对鼠标"):L10n.tr("绝对鼠标"),L10n.tr("显示器"),L10n.tr("画面设置"),muted?L10n.tr("静音"):L10n.tr("声音"),L10n.tr("发送文件"),L10n.tr("网络状态"),"Ctrl+Alt+Del",L10n.tr("剪贴板"),L10n.tr("隐私屏")};
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard","eye"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},this::toggleMouse,actions::mouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard,actions::togglePrivacy};
menuColumns=controlColumns();
for(int row=0;row<(labels.length+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int index=row*menuColumns+col;if(index>=labels.length){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
java.util.List<Integer> visibleControls=new java.util.ArrayList<>();
for(int index=0;index<labels.length;index++){
if(index==8&&!secureAttentionVisible||index==10&&!privacy.desktopAvailable)continue;
visibleControls.add(index);
}
int controlCount=visibleControls.size();
for(int row=0;row<(controlCount+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int position=row*menuColumns+col;if(position>=controlCount){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}int index=visibleControls.get(position);LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));MobileUi.Icon icon=ui.icon(icons[index],INK);control.addView(icon,ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setMaxLines(2);caption.setAutoSizeTextTypeUniformWithConfiguration(8,10,1,android.util.TypedValue.COMPLEX_UNIT_SP);caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
if(index==10){privacyButton=control;privacyIcon=icon;privacyCaption=caption;}
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
}body.addView(line);if((row+1)*menuColumns<labels.length)ui.gap(body,7);}
}body.addView(line);if((row+1)*menuColumns<controlCount)ui.gap(body,7);}
privacyFeedback=ui.text("",11,false);privacyFeedback.setTextColor(SECONDARY);privacyFeedback.setPadding(0,ui.dp(7),0,0);privacyFeedback.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(privacyFeedback);renderPrivacy();
ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel);
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
saveFileButton=ui.action(L10n.tr("保存收到的文件"),BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
}else if(page.equals("network")){
networkPage(body);
}else{
settingRows.add(new SettingRow(body,0,"画面质量",new String[]{"低","中","高"},new int[]{0,1,2}));ui.gap(body,12);
settingRows.add(new SettingRow(body,1,"画面采集帧率",new String[]{"30 fps","60 fps"},new int[]{30,60}));ui.gap(body,12);
settingRows.add(new SettingRow(body,2,"画面偏好",RemoteVideoSettings.PREFERENCES,new int[]{0,1,2}));renderVideoSettings();
settingRows.add(new SettingRow(body,0,L10n.tr("画面质量"),new String[]{L10n.tr("低"),L10n.tr("中"),L10n.tr("高")},new int[]{0,1,2}));ui.gap(body,12);
settingRows.add(new SettingRow(body,1,L10n.tr("画面采集帧率"),new String[]{"30 fps","60 fps"},new int[]{30,60}));ui.gap(body,12);
settingRows.add(new SettingRow(body,2,L10n.tr("画面偏好"),RemoteVideoSettings.preferences(),new int[]{0,1,2}));renderVideoSettings();
}
ui.gap(menu,9);TextView disconnect=ui.action("断开连接",Color.WHITE,()->{closeMenu();closeKeyboard();actions.disconnect();});disconnect.setMinHeight(ui.dp(34));disconnect.setTextSize(14);disconnect.setBackground(ui.background(RED,8));menu.addView(disconnect,ui.size(-1,34));
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
ui.gap(menu,9);TextView disconnect=ui.action(L10n.tr("断开连接"),Color.WHITE,()->{closeMenu();closeKeyboard();actions.disconnect();});disconnect.setMinHeight(ui.dp(34));disconnect.setTextSize(14);disconnect.setBackground(ui.background(RED,8));menu.addView(disconnect,ui.size(-1,34));
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription(L10n.tr("收起远程控制菜单"));
positionMouse();
}
private void renderPrivacy(){
if(menu!=null&&menuPage.equals("controls")&&(privacyButton!=null)!=privacy.desktopAvailable){
openMenu("controls");return;
}
long now=android.os.SystemClock.elapsedRealtime();privacy.tick(now);
if(privacyFeedback!=null){String feedback=privacy.feedback(now);privacyFeedback.setText(feedback);privacyFeedback.setVisibility(feedback.isEmpty()?GONE:VISIBLE);}
if(privacyButton==null)return;
boolean enabled=privacy.enabled(now);String action=privacy.active()?L10n.tr("关闭隐私屏"):L10n.tr("开启隐私屏");
privacyIcon.setName(enabled?"eye_off":"eye");
privacyCaption.setText(privacy.buttonTitle(now));
privacyButton.setSelected(enabled);
privacyButton.setContentDescription(action+","+privacy.status(now));
privacyButton.setEnabled(privacy.canToggle(now));privacyButton.setAlpha(privacyButton.isEnabled()?1f:.45f);
}
private final class SettingRow {
final int field;final int[] values;final SegmentedControl group;
SettingRow(LinearLayout body,int field,String title,String[] labels,int[] values){
@@ -141,7 +183,7 @@ final class SessionControls extends FrameLayout {
group=new SegmentedControl(ui,labels,selectionIndex(),settings.supported,index->actions.videoSettings(field,values[index]));
group.setPadding(ui.dp(2),ui.dp(2),ui.dp(2),ui.dp(2));body.addView(group);
for(int i=0;i<labels.length;i++){
TextView button=(TextView)group.getChildAt(i);button.setTextSize(12);button.setMinHeight(ui.dp(28));button.setSingleLine();button.setContentDescription(title+","+labels[i]);
TextView button=(TextView)group.getChildAt(i);button.setTextSize(12);button.setMinHeight(ui.dp(28));button.setSingleLine();button.setAutoSizeTextTypeUniformWithConfiguration(9,12,1,android.util.TypedValue.COMPLEX_UNIT_SP);button.setContentDescription(title+","+labels[i]);
button.setLayoutParams(new LinearLayout.LayoutParams(0,ui.dp(28),1));
}
}
@@ -155,15 +197,17 @@ final class SessionControls extends FrameLayout {
for(SettingRow row:settingRows)row.render();
}
private void networkPage(LinearLayout body){
trafficCells=new TextView[4][3];trafficRows=new LinearLayout[4];String[] names={"视频","音频","数据","合计"};
LinearLayout heading=trafficRow(body,"",new TextView[3],false);((TextView)heading.getChildAt(1)).setText("接收");((TextView)heading.getChildAt(2)).setText("发送");((TextView)heading.getChildAt(3)).setText("丢包率");
trafficCells=new TextView[4][3];trafficRows=new LinearLayout[4];String[] names={L10n.tr("视频"),L10n.tr("音频"),L10n.tr("数据"),L10n.tr("合计")};
LinearLayout heading=trafficRow(body,"",new TextView[3],false);((TextView)heading.getChildAt(1)).setText(L10n.tr("接收"));((TextView)heading.getChildAt(2)).setText(L10n.tr("发送"));((TextView)heading.getChildAt(3)).setText(L10n.tr("丢包率"));
for(int i=0;i<heading.getChildCount();i++)((TextView)heading.getChildAt(i)).setTextColor(SECONDARY);
for(int i=0;i<4;i++)trafficRows[i]=trafficRow(body,names[i],trafficCells[i],i==3);
ui.gap(body,10);ui.divider(body);ui.gap(body,10);
String[] titles={"帧率","分辨率","画面延时","连接延时(RTT)","连接方式","媒体加密"};networkDetails=new TextView[titles.length];
for(int i=0;i<titles.length;i++){LinearLayout row=ui.row();TextView title=ui.text(titles[i],12,false);title.setTextColor(SECONDARY);row.addView(title,new LinearLayout.LayoutParams(0,-2,1));
String[] titles={L10n.tr("帧率"),L10n.tr("分辨率"),L10n.tr("画面延时"),L10n.tr("连接延时(RTT)"),L10n.tr("连接方式"),L10n.tr("媒体加密"),L10n.tr("被控端编码器"),L10n.tr("控制端解码器")};networkDetails=new TextView[titles.length];
for(int i=0;i<titles.length;i++){LinearLayout row=ui.row();TextView title=ui.text(titles[i],12,false);title.setTextColor(SECONDARY);row.addView(title,i>=6?new LinearLayout.LayoutParams(-2,-2):new LinearLayout.LayoutParams(0,-2,1));
if(i==5){encryptionLock=ui.icon("lock",GREEN);encryptionOpen=ui.icon("unlock",SECONDARY);for(View icon:new View[]{encryptionLock,encryptionOpen}){LinearLayout.LayoutParams p=ui.size(14,14);p.rightMargin=ui.dp(4);row.addView(icon,p);}}
TextView value=ui.text("—",12,false);value.setFontFeatureSettings("tnum");row.addView(value);networkDetails[i]=value;body.addView(row);if(i<titles.length-1)ui.gap(body,7);}
TextView value=ui.text("—",12,false);value.setFontFeatureSettings("tnum");
if(i>=6){value.setGravity(Gravity.END);value.setSingleLine();value.setEllipsize(android.text.TextUtils.TruncateAt.MARQUEE);value.setMarqueeRepeatLimit(-1);value.setSelected(true);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,-2,1);p.leftMargin=ui.dp(8);row.addView(value,p);}else row.addView(value);
networkDetails[i]=value;body.addView(row);if(i<titles.length-1)ui.gap(body,7);}
renderNetwork();
}
private LinearLayout trafficRow(LinearLayout body,String title,TextView[] cells,boolean total){
@@ -174,16 +218,16 @@ final class SessionControls extends FrameLayout {
return row;
}
private void renderNetwork(){
if(networkLabel!=null)networkLabel.setText(network.summary());
if(trafficCells==null)return;
String[] names={"视频","音频","数据","合计"};
String[] names={L10n.tr("视频"),L10n.tr("音频"),L10n.tr("数据"),L10n.tr("合计")};
for(int i=0;i<4;i++){
RemoteNetworkStatistics.Traffic traffic=network.report==null?null:network.report.traffic[i];
String in=RemoteNetworkStatistics.bitrate(traffic==null?-1:traffic.inbound),out=RemoteNetworkStatistics.bitrate(traffic==null?-1:traffic.outbound),loss=RemoteNetworkStatistics.loss(traffic==null?Double.NaN:traffic.loss);
trafficCells[i][0].setText(in);trafficCells[i][1].setText(out);trafficCells[i][2].setText(loss);trafficRows[i].setContentDescription(names[i]+",接收 "+in+",发送 "+out+",丢包率 "+loss);
trafficCells[i][0].setText(in);trafficCells[i][1].setText(out);trafficCells[i][2].setText(loss);trafficRows[i].setContentDescription(L10n.format("{0},接收 {1},发送 {2},丢包率 {3}",names[i],in,out,loss));
}
String[] values={network.fps+" FPS",network.resolution(),RemoteNetworkStatistics.latency(network.latency),RemoteNetworkStatistics.latency(network.rtt),network.mode(),network.report==null?"—":network.report.srtp?"SRTP 已启用":"未启用"};
for(int i=0;i<values.length;i++)networkDetails[i].setText(values[i]);networkDetails[5].setTextColor(network.report!=null&&network.report.srtp?GREEN:SECONDARY);
String[] values={network.fps+" FPS",network.resolution(),RemoteNetworkStatistics.latency(network.latency),RemoteNetworkStatistics.latency(network.rtt),network.mode(),network.report==null?"—":network.report.srtp?L10n.tr("SRTP 已启用"):L10n.tr("未启用"),network.remoteEncoder.isEmpty()?"—":network.remoteEncoder,network.localDecoder.isEmpty()?"—":network.localDecoder};
// Reassigning identical text every statistics tick restarts the marquee.
for(int i=0;i<values.length;i++)if(!android.text.TextUtils.equals(networkDetails[i].getText(),values[i]))networkDetails[i].setText(values[i]);networkDetails[5].setTextColor(network.report!=null&&network.report.srtp?GREEN:SECONDARY);
encryptionLock.setVisibility(network.report!=null&&network.report.srtp?VISIBLE:GONE);encryptionOpen.setVisibility(network.report!=null&&!network.report.srtp?VISIBLE:GONE);
}
private int controlColumns(){return getMeasuredHeight()<ui.dp(400)&&getMeasuredWidth()>=ui.dp(400)?4:3;}
@@ -201,38 +245,11 @@ final class SessionControls extends FrameLayout {
return Math.min(contentHeight,Math.max(1,height-ui.dp(16)));
}
private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));}
private int keyboardWidth(int width){return Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,width-ui.dp(16)));}
private int keyboardHeight(int height){return Math.min(keyboardPreferredHeight,Math.max(1,height-ui.dp(16)));}
private void positionKeyboard(){if(keyboard!=null){keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-keyboard.getWidth()-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}positionMouse();}
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;keyboardKeys=null;}}
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
private void showKeyboard(){
float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();int oldScroll=keyboardKeys==null?0:keyboardKeys.getScrollY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10));
LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{});
// Keep the drag/close row fixed while short split windows can scroll every key into view.
keyboardKeys=new ScrollView(getContext());keyboard.addView(keyboardKeys,new LinearLayout.LayoutParams(-1,0,1));LinearLayout keys=ui.column();keyboardKeys.addView(keys);
String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}};
for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keys.addView(line);ui.gap(keys,3);}
keyboardPreferredHeight=ui.dp(5)*2+ui.dp(28)+rows.length*(ui.dp(31)+ui.dp(3));
addView(keyboard,new LayoutParams(keyboardWidth(getWidth()),keyboardHeight(getHeight())));LinearLayout shownKeyboard=keyboard;ScrollView shownKeys=keyboardKeys;
keyboard.post(()->{if(keyboard==shownKeyboard){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);shownKeys.scrollTo(0,oldScroll);constrainPanels();}});
}
private void keyboardKey(String value){
switch(value){
case "⇧":shift=!shift;showKeyboard();return;
case "电脑":computerKeyboard=true;showKeyboard();return;
case "ABC":computerKeyboard=false;showKeyboard();return;
case "剪贴板":actions.clipboard();return;
case "复制":actions.shortcut(0x43);return;case "粘贴":actions.shortcut(0x56);return;case "全选":actions.shortcut(0x41);return;
case "空格":sendKey(32);return;case "⌫":sendKey(8);return;case "Enter":sendKey(13);return;case "Esc":sendKey(27);return;case "Tab":sendKey(9);return;
case "↑":sendKey(38);return;case "↓":sendKey(40);return;case "←":sendKey(37);return;case "→":sendKey(39);return;
// Latch locally; the next key sends a complete chord without leaving remote modifiers down.
case "Ctrl":toggleModifier(17);return;case "Alt":toggleModifier(18);return;case "Win":toggleModifier(91);return;
default:if(value.matches("F[1-6]")){sendKey(111+Integer.parseInt(value.substring(1)));return;}if(!modifiers.isEmpty())sendKey(KeyMap.ascii(value.charAt(0))&255);else actions.text(shift?value.toUpperCase(java.util.Locale.ROOT):value);
}
}
private void toggleModifier(int code){if(!modifiers.add(code))modifiers.remove(code);showKeyboard();}
private void sendKey(int code){if(modifiers.isEmpty())actions.key(code);else{actions.chord(code,modifiers.stream().mapToInt(Integer::intValue).toArray());modifiers.clear();showKeyboard();}}
private void positionKeyboard(){positionMouse();}
boolean closeKeyboard(){return keyboard.close();}
void resetKeyboardContext(){keyboard.resetContext();}
void keyboardInsets(int height,boolean visible,int navigation){keyboard.updateInsets(height,visible,navigation);}
private void toggleKeyboard(){keyboard.bringToFront();keyboard.toggle();}
@android.annotation.SuppressLint("ClickableViewAccessibility") // Tap dispatches performClick; dragging is an additional action.
private void drag(View handle,View target,Runnable tap){
handle.setOnClickListener(v->tap.run());handle.setOnTouchListener(new View.OnTouchListener(){float startX,startY,originX,originY;boolean moved;
@@ -251,7 +268,7 @@ final class SessionControls extends FrameLayout {
cancelMouseInput();
if(menu!=null&&menuPage.equals("controls")&&menuColumns!=controlColumns())openMenu("controls");
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
if(menu!=null||keyboard!=null)requestLayout();
if(menu!=null||keyboard.isOpen())requestLayout();
positionKeyboard();
positionMouse();
}
@@ -265,9 +282,10 @@ final class SessionControls extends FrameLayout {
// measure again with the final cap, even when it did not change.
p.width=width;p.height=height;resized=true;
}
if(keyboard!=null){
ViewGroup.LayoutParams p=keyboard.getLayoutParams();int width=keyboardWidth(getMeasuredWidth()),height=keyboardHeight(getMeasuredHeight());
if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;}
if(keyboard.isOpen()){
LayoutParams p=(LayoutParams)keyboard.getLayoutParams();
int height=keyboard.preferredHeight(),inset=keyboard.bottomInset();
if(p.height!=height||p.bottomMargin!=inset){p.height=height;p.bottomMargin=inset;resized=true;}
}
if(resized)super.onMeasure(widthSpec,heightSpec);
}
@@ -280,7 +298,8 @@ final class SessionControls extends FrameLayout {
positionKeyboard();
positionMouse();
}
@Override protected void onDetachedFromWindow(){cancelMouseInput();super.onDetachedFromWindow();}
@Override protected void onAttachedToWindow(){super.onAttachedToWindow();if(android.os.Build.VERSION.SDK_INT<30)getViewTreeObserver().addOnGlobalLayoutListener(legacyKeyboardLayout);}
@Override protected void onDetachedFromWindow(){getViewTreeObserver().removeOnGlobalLayoutListener(legacyKeyboardLayout);cancelMouseInput();super.onDetachedFromWindow();}
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);}
private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));}
}
@@ -29,11 +29,11 @@ final class VirtualMouseBar extends LinearLayout {
shell.setStroke(ui.dp(.5f), 0xFFD4DAE2);
setBackground(shell);
setMotionEventSplittingEnabled(true);
keys[0] = key("鼠标左键,按住并拖动,松开结束拖拽", 0);
keys[1] = key("鼠标滚轮,上下滑动,可滑出按键范围", 1);
keys[2] = key("鼠标右键,按住并拖动,松开结束拖拽", 2);
keys[0] = key(L10n.tr("鼠标左键,按住并拖动,松开结束拖拽"), 0);
keys[1] = key(L10n.tr("鼠标滚轮,上下滑动,可滑出按键范围"), 1);
keys[2] = key(L10n.tr("鼠标右键,按住并拖动,松开结束拖拽"), 2);
input.didCancel = this::resetTouches;
setContentDescription("虚拟鼠标");
setContentDescription(L10n.tr("虚拟鼠标"));
}
@SuppressLint("ClickableViewAccessibility") // Touch sends real down/up; performClick serves accessibility only.
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" · 已完成"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" 传输失败"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" 台设备"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" 条 · "</string>
<string name="text_2c894057fc526576" formatted="false">" 条未读"</string>
<string name="text_d0ac753c6681e578" formatted="false">"CrossDesk 远程连接"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL 第 3 版"</string>
<string name="text_5ae7dc4079223967" formatted="false">"OpenFEC 告知"</string>
<string name="text_69ad29820a259526" formatted="false">"OpenFEC 许可与源码"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"P2P 直连"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRTP 已启用"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"TURN 中继"</string>
<string name="text_ebede975edd52aee" formatted="false">"{0} 传输失败"</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"{0} 台设备"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"{0} 条未读"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0},接收 {1},发送 {2},丢包率 {3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● 已连接"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"下拉刷新"</string>
<string name="text_37772187ab854191" formatted="false">"丢包率"</string>
<string name="text_a567bdaa11367f26" formatted="false">"中"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"优先保持画面流畅,带宽或编码压力较大时会更积极地降低分辨率。"</string>
<string name="text_aa9e366f68d3d097" formatted="false">"低"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"使用自定义服务器"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"保存密码"</string>
<string name="text_10019cef593819ed" formatted="false">"保存收到的文件"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"保存远程画面预览"</string>
<string name="text_0f197074373e38fd" formatted="false">"保存远程画面预览?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"信令连接中断,请重新连接"</string>
<string name="text_789f22a8b1b34797" formatted="false">"公告加载失败,请重试。"</string>
<string name="text_00554b17c432937e" formatted="false">"公告详情"</string>
<string name="text_2428420e407fe2e8" formatted="false">"共 "</string>
<string name="text_d977c7568cc5201e" formatted="false">"共 {0} 条 · {1} 条未读"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"关于"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"关于 CrossDesk"</string>
<string name="text_390a95b65eb33052" formatted="false">"关于,有新版本"</string>
<string name="text_3fd47edce45b3603" formatted="false">"关闭"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"关闭控制栏"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"关闭隐私屏"</string>
<string name="text_7d466b69be42fc43" formatted="false">"兼顾清晰度和流畅度,只在持续压力下逐步降低分辨率。"</string>
<string name="text_b92421822c213c2b" formatted="false">"分辨率"</string>
<string name="text_2f9daa828907b93f" formatted="false">"删除"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"删除公告"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"删除记录"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"删除这条公告?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"刷新公告"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"剪贴板"</string>
<string name="text_4927a53bcc886afb" formatted="false">"加载中…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"加载更多"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"升级提示"</string>
<string name="text_861521c9e107c648" formatted="false">"原生连接初始化失败"</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"发送"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"发送 "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"发送后,在远程键盘中点击“粘贴”。接收的远端文本可手动复制到本机。"</string>
<string name="text_e7281fbca7af369c" formatted="false">"发送文件"</string>
<string name="text_ce6c02d19022e016" formatted="false">"发送文本"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"取消"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"取消连接"</string>
<string name="text_72814b15a6fa152c" formatted="false">"各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。"</string>
<string name="text_e009c148d1890dac" formatted="false">"合计"</string>
<string name="text_81c92013219dd115" formatted="false">"同意并继续"</string>
<string name="text_409696774c9998ce" formatted="false">"后台保持连接,点按返回远程桌面"</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"后台保活未能启动,请保持应用在前台"</string>
<string name="text_d96a21463557171d" formatted="false">"后台保活未能启动,请保持应用在前台后重新连接"</string>
<string name="text_952f7300de0a7bd9" formatted="false">"后台连接已结束,请重新连接"</string>
<string name="text_da904f715e1cff87" formatted="false">"后台连接服务已停止,请重新连接"</string>
<string name="text_0812359e18082071" formatted="false">"后续连接仍会保存新的预览图。"</string>
<string name="text_b9086662b1df1f20" formatted="false">"在线"</string>
<string name="text_39f2f444a042643e" formatted="false">"填写主机名或 IPv4 地址,不含协议和路径"</string>
<string name="text_473400815c0f687d" formatted="false">"声音"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"复制远端文本"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"始终保持较高分辨率,带宽不足时通过降低帧率来保证画面清晰度。"</string>
<string name="text_7ff73de85ec16d15" formatted="false">"媒体加密"</string>
<string name="text_22dc6217af77bc37" formatted="false">"字母数字"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"安全存储或证书初始化失败,请重试或清除应用数据"</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"完成"</string>
<string name="text_678328f6f02881c7" formatted="false">"完成隐私授权后可检查更新"</string>
<string name="text_c4361f9abdba4882" formatted="false">"完成隐私授权后登记本机身份"</string>
<string name="text_c29b888e357d727d" formatted="false">"密码保存失败,本次连接仍可使用"</string>
<string name="text_3f31300a23afb390" formatted="false">"密码将加密保存在本机 Android Keystore 中"</string>
<string name="text_8d3b052c4c67acda" formatted="false">"对端 ID"</string>
<string name="text_0512084e130984bc" formatted="false">"对端 ID "</string>
<string name="text_c5147d65960196b0" formatted="false">"对端 ID {0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"将删除本机保存的所有远程预览图,保留连接记录和密码。"</string>
<string name="text_60cc99cc0013972e" formatted="false">"屏幕方向"</string>
<string name="text_9e7294d264afecb7" formatted="false">"展开远程控制菜单"</string>
<string name="text_9343612ee473edd6" formatted="false">"已从通知断开远程连接"</string>
<string name="text_9d32476fe83aef60" formatted="false">"已保存密码"</string>
<string name="text_1cbd391383283924" formatted="false">"已保存密码无法读取,请重新输入"</string>
<string name="text_6792b9f341380c68" formatted="false">"已保存的密码未能保留,请重新输入一次"</string>
<string name="text_74d3f0927d465047" formatted="false">"已发送 Ctrl+Alt+Del"</string>
<string name="text_c28d86acbedba3b6" formatted="false">"已启用后台保活,切换应用可继续保持连接"</string>
<string name="text_506409ec08cb2a79" formatted="false">"已启用后台保活,可从连接通知返回或断开"</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"已授权"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"已读"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"已连接 "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"已连接服务器"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"帧率"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"帧率优先"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"平衡"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"应用中的许可文件缺失或损坏,请重新安装应用。"</string>
<string name="text_3f2904435942cc0f" formatted="false">"开发版本包含尚未发布的修改,以下链接为基础版本源码。"</string>
<string name="text_4338afaad52ea445" formatted="false">"开启保存"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"开启隐私屏"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"开源组件"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"开源软件权利说明"</string>
<string name="text_6e86570183c3cdd0" formatted="false">"当前已是最新版本"</string>
<string name="text_24a52788cf474bf6" formatted="false">"当前远端不支持直接输入此文本,请使用支持文本输入的被控端版本,或通过剪贴板发送"</string>
<string name="text_33f25b500697a7d9" formatted="false">"成功连接后,这里会显示最近的远程设备。"</string>
<string name="text_70341256d7317e7d" formatted="false">"我已阅读并同意《隐私政策》"</string>
<string name="text_85c4c8fda0461f35" formatted="false">"所选文件(无法读取或空间不足)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"找不到该设备 ID"</string>
<string name="text_97b7b18df07b9787" formatted="false">"指令发送失败,请重试"</string>
<string name="text_cb782027ca5986c7" formatted="false">"指令已发送,等待电脑确认…"</string>
<string name="text_3ca40a586103f6b7" formatted="false">"授权状态"</string>
<string name="text_6e684586884ebee6" formatted="false">"接收"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"接收 "</string>
<string name="text_d16488b987723161" formatted="false">"控制端解码器"</string>
<string name="text_6b58198955259066" formatted="false">"撤回"</string>
<string name="text_c52e42ee856825db" formatted="false">"撤回后将停止远程连接、关闭画面预览保存并清除已有预览。连接记录和密码会保留,重新连接前需再次授权。"</string>
<string name="text_61f4788032ec596b" formatted="false">"撤回联网授权"</string>
<string name="text_e736e419008ac10a" formatted="false">"撤回联网授权?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"收起远程控制菜单"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"收起键盘"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"收起鼠标"</string>
<string name="text_5440f7424f2865bf" formatted="false">"数据"</string>
<string name="text_94244f41d0a41378" formatted="false">"文件保存失败,请重试"</string>
<string name="text_2553a785fef2a405" formatted="false">"文件已保存"</string>
<string name="text_d573b3f9a810ea04" formatted="false">"文件选择已超时,会话已结束"</string>
<string name="text_985a5c380be49f4f" formatted="false">"文本超过 128 KB"</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"断开"</string>
<string name="text_09b1395f50589327" formatted="false">"断开后将返回首页"</string>
<string name="text_a73c0680db0d2e06" formatted="false">"断开远程连接?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"断开连接"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"新版本"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"新版本可用:v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"新版本可用:v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"无未读公告"</string>
<string name="text_dca2c418ac967366" formatted="false">"无法保存本地阅读状态,请重试。"</string>
<string name="text_5c92da76360056cc" formatted="false">"无法删除这条公告,请重试。"</string>
<string name="text_615709dd3129313c" formatted="false">"无法发起远程连接"</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"无法读取开源许可"</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"显示器"</string>
<string name="text_ffe21a671190323e" formatted="false">"显示密码"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"暂不同意"</string>
<string name="text_6d9a7758610a2432" formatted="false">"暂无公告"</string>
<string name="text_0b290c6600d92f43" formatted="false">"最近连接"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"服务器"</string>
<string name="text_dfe408e686175095" formatted="false">"服务器 TLS 证书校验失败"</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"服务器地址"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"服务器登录失败,请检查服务器地址和网络"</string>
<string name="text_d270868960e68629" formatted="false">"服务器端口"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"服务器连接超时,请检查服务器地址和网络"</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"未启用"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"未找到可打开链接的浏览器"</string>
<string name="text_94bc3d40defef3e9" formatted="false">"未授权"</string>
<string name="text_4d0680f9efaef147" formatted="false">"未读"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"未连接服务器 · 点击重试"</string>
<string name="text_7eea7966c0b746c5" formatted="false">"本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。"</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"本机 ID "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"本机 ID {0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"本版本源码与构建说明"</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"本版本源码信息暂不可用。"</string>
<string name="text_f0e274aa130e08a2" formatted="false">"松开刷新"</string>
<string name="text_47077144715b7074" formatted="false">"构建说明"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"构建配方与项目补丁"</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"查看源码"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"检查失败,请稍后重试"</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"检查更新"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"正在关闭隐私屏…"</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"正在建立安全连接…"</string>
<string name="text_0da575e3c92de73a" formatted="false">"正在开启隐私屏…"</string>
<string name="text_91834aebecabcbc3" formatted="false">"正在检查更新…"</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"正在清除…"</string>
<string name="text_0e6336c8fe918730" formatted="false">"正在获取本机 ID…"</string>
<string name="text_faebd20c2801fd0f" formatted="false">"正在读取所选文件…"</string>
<string name="text_694186911ae3e4da" formatted="false">"正在连接"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"正在连接信令服务器…"</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"正在连接服务器…"</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"正在重连服务器…"</string>
<string name="text_4e4fce649fc00238" formatted="false">"此设备没有可用的文件选择器"</string>
<string name="text_bce2377283c2455a" formatted="false">"清除"</string>
<string name="text_ee0e76b8507212de" formatted="false">"清除预览图"</string>
<string name="text_ecc809e44637de18" formatted="false">"清除预览图?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"源码与构建说明"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"源码版本"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"版本"</string>
<string name="text_16410ee9663fad10" formatted="false">"版权与许可声明"</string>
<string name="text_11de77e1e41e680f" formatted="false">"电脑键盘"</string>
<string name="text_20f57900234debd7" formatted="false">"画质优先"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"画面偏好"</string>
<string name="text_82a5600c470179fa" formatted="false">"画面延时"</string>
<string name="text_33b458faaf967e15" formatted="false">"画面设置"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"画面质量"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"画面采集帧率"</string>
<string name="text_66062971234f422d" formatted="false">"相对位置"</string>
<string name="text_c421dc051bea3f11" formatted="false">"相对鼠标"</string>
<string name="text_de32e20193ad1042" formatted="false">"知道了"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"确定"</string>
<string name="text_36f33adaf0942634" formatted="false">"确认"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"确认连接"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"离线"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"空格"</string>
<string name="text_471a579e8d87faac" formatted="false">"端口范围为 1–65535"</string>
<string name="text_c7d0c77862e32b52" formatted="false">"符号功能"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"等待电脑返回隐私屏状态;旧版电脑端可能不支持"</string>
<string name="text_5ee880e2524a983a" formatted="false">"等待远端屏幕信息"</string>
<string name="text_674c723a85ee7f57" formatted="false">"等待远端画面…"</string>
<string name="text_119f0483da4756c4" formatted="false">"等待隐私授权"</string>
<string name="text_7916071c1a8ee458" formatted="false">"系统键盘"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"绝对位置"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"绝对鼠标"</string>
<string name="text_872428a7210d5e0c" formatted="false">"网络状态"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"自定义"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"虚拟鼠标"</string>
<string name="text_62c199f37bf95869" formatted="false">"被控端已通过组合键解除隐私屏。点击“锁定”将重新开启隐私屏,并禁止本机使用组合键解锁。"</string>
<string name="text_a569ce729c1d6e19" formatted="false">"被控端版本过低,请升级到最新版本。"</string>
<string name="text_d814476826827b78" formatted="false">"被控端编码器"</string>
<string name="text_488402883e27f6f0" formatted="false">"要发送到远端的文本"</string>
<string name="text_c20f7618d330a854" formatted="false">"视频"</string>
<string name="text_310c625425f014d8" formatted="false">"许可"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"许可与版权声明"</string>
<string name="text_e24134b643a17975" formatted="false">"许可与版权声明可离线查阅。"</string>
<string name="text_5e7628f043ce71d0" formatted="false">"设备身份保存失败,请检查安全存储"</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"设备身份及选择保存的访问密码使用 Android Keystore 加密。连接记录仅保存在本机,不纳入设备备份。剪贴板内容在结束会话时清除。离开应用后通过画中画或连接通知保持会话,可在应用或通知中断开。"</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"设置"</string>
<string name="text_4f8771952222a011" formatted="false">"设置,有新版本"</string>
<string name="text_f4f712d5be91721a" formatted="false">"访问密码"</string>
<string name="text_dd6c465847af1b60" formatted="false">"访问密码错误,请重新输入"</string>
<string name="text_defba1d2989bfab4" formatted="false">"证书校验失败 · 点击重试"</string>
<string name="text_44f3d4d52347095a" formatted="false">"请先完成联网授权"</string>
<string name="text_2a07ca002bc5093d" formatted="false">"请在远端启用 CrossDesk 系统服务后重试"</string>
<string name="text_990fd23c62de5ccf" formatted="false">"请求超时,尚未确认电脑的隐私屏状态"</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"请等待当前文件发送完成"</string>
<string name="text_9305d9d72b06110d" formatted="false">"请输入 1–65535 范围内的端口"</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"请输入 9 位设备 ID"</string>
<string name="text_285a0714894d2899" formatted="false">"请输入有效访问密码"</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"调整失败,请重试。"</string>
<string name="text_a762e7e650e285fa" formatted="false">"软件许可"</string>
<string name="text_572cf45ba43634b3" formatted="false">"返回"</string>
<string name="text_7565f94031582c7c" formatted="false">"返回控制栏"</string>
<string name="text_55113b6925c362fd" formatted="false">"还没有连接记录"</string>
<string name="text_3aa527b2106cc987" formatted="false">"这条公告已更新或删除,请返回公告列表查看。"</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"远程任务已关闭"</string>
<string name="text_c255841f68096d3b" formatted="false">"远程桌面"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"远程桌面触控区域"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"远程桌面:轻触点击,拖动移动,长按右键,双指捏合缩放,放大后双指拖动画面,原始比例下双指上下滑动滚动"</string>
<string name="text_3535087994d050de" formatted="false">"远程电脑"</string>
<string name="text_ed2e03072ce131de" formatted="false">"远程窗口已关闭,或后台保活未能启动,会话已结束"</string>
<string name="text_678b783fb578172b" formatted="false">"远程连接"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"远端密码错误"</string>
<string name="text_a2ce499295e358ae" formatted="false">"远端已关闭连接"</string>
<string name="text_176e117df3ace734" formatted="false">"远端当前不可用"</string>
<string name="text_d2c0694243f93249" formatted="false">"远端访问密码已失效,请重新连接"</string>
<string name="text_a5574109f0208e89" formatted="false">"连接"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"连接 "</string>
<string name="text_89c4766afc85125f" formatted="false">"连接失败"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"连接失败 · 点击重试"</string>
<string name="text_2ab7d89557e779ab" formatted="false">"连接已断开"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"连接已结束"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"连接延时(RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"连接建立后可调整,需被控端支持。"</string>
<string name="text_2123a9ed718f2c40" formatted="false">"连接建立后锁定横屏"</string>
<string name="text_f741e0536210eafa" formatted="false">"连接提示"</string>
<string name="text_485a26050ce57431" formatted="false">"连接方式"</string>
<string name="text_5a048351a77fe88d" formatted="false">"连接服务器"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"连接服务器后可获取最新公告。"</string>
<string name="text_32cd27b5a72515f3" formatted="false">"连接超时,请检查设备 ID、服务器及远端在线状态"</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"连接远程桌面"</string>
<string name="text_d5407b4f3c666681" formatted="false">"退格"</string>
<string name="text_e02926784fcedb91" formatted="false">"通知公告"</string>
<string name="text_465a092680d132b3" formatted="false">"部分预览图未能清除,请重试。"</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"重试"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"重试保存阅读状态"</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"锁定"</string>
<string name="text_864200bbb241e067" formatted="false">"键盘"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"阅读并授权"</string>
<string name="text_86651d17a401c55b" formatted="false">"隐私"</string>
<string name="text_26c6e011e343708c" formatted="false">"隐私与授权"</string>
<string name="text_6cad42fd3da85585" formatted="false">"隐私屏"</string>
<string name="text_e157fafb76aad954" formatted="false">"隐私屏已关闭"</string>
<string name="text_bbef0eac0c04f214" formatted="false">"隐私屏已开启"</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"隐私屏已被解锁"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"隐私屏异常"</string>
<string name="text_598a2747dbd21dd5" formatted="false">"隐私屏暂不可用"</string>
<string name="text_4d59be3c75189032" formatted="false">"隐私屏状态已过期,等待电脑确认"</string>
<string name="text_1674532c70ed1db2" formatted="false">"隐私屏状态待确认"</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"隐私政策"</string>
<string name="text_554e957e8e3c00af" formatted="false">"隐私政策暂时无法读取,请拒绝联网并重新安装应用。"</string>
<string name="text_7db2e189ecb01065" formatted="false">"静音"</string>
<string name="text_296c632ec857a0ba" formatted="false">"音频"</string>
<string name="text_0cadb49096a382e0" formatted="false">"项目主页"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"预览可能包含远程电脑上的个人或工作信息。开启后,每次连接会自动将一张画面保存在本机并显示在最近连接中。你可以随时关闭并清除。"</string>
<string name="text_b1c27820fec23edb" formatted="false">"高"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"默认"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"默认关闭。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受影响。"</string>
<string name="text_1184a97edd8e1646" formatted="false">"鼠标右键,按住并拖动,松开结束拖拽"</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"鼠标左键,按住并拖动,松开结束拖拽"</string>
<string name="text_269f39a07b4a4e01" formatted="false">"鼠标控制"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"鼠标滚轮,上下滑动,可滑出按键范围"</string>
<string name="text_9aa238fc6121dede" formatted="false">",丢包率 "</string>
<string name="text_0b5d16c21095fe10" formatted="false">",发送 "</string>
<string name="text_ba9a792d063a8c86" formatted="false">",已保存密码"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">",接收 "</string>
<string name="text_42237b5ec70c26f3" formatted="false">",点击重试"</string>
</resources>
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" · 已完成"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" 傳輸失敗"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" 臺裝置"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" 條 · "</string>
<string name="text_2c894057fc526576" formatted="false">" 條未讀"</string>
<string name="text_d0ac753c6681e578" formatted="false">"CrossDesk 遠端連線"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL 第 3 版"</string>
<string name="text_5ae7dc4079223967" formatted="false">"OpenFEC 告知"</string>
<string name="text_69ad29820a259526" formatted="false">"OpenFEC 許可與原始碼"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"P2P 直連"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRTP 已啟用"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"TURN 中繼"</string>
<string name="text_ebede975edd52aee" formatted="false">"{0} 傳輸失敗"</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"{0} 臺裝置"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"{0} 條未讀"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0},接收 {1},傳送 {2},丟包率 {3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● 已連線"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"下拉重新整理"</string>
<string name="text_37772187ab854191" formatted="false">"丟包率"</string>
<string name="text_a567bdaa11367f26" formatted="false">"中"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"優先保持畫面流暢,頻寬或編碼壓力較大時會更積極地降低解析度。"</string>
<string name="text_aa9e366f68d3d097" formatted="false">"低"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"使用自定義伺服器"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"儲存密碼"</string>
<string name="text_10019cef593819ed" formatted="false">"儲存收到的檔案"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"儲存遠端畫面預覽"</string>
<string name="text_0f197074373e38fd" formatted="false">"儲存遠端畫面預覽?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"信令連線中斷,請重新連線"</string>
<string name="text_789f22a8b1b34797" formatted="false">"公告載入失敗,請重試。"</string>
<string name="text_00554b17c432937e" formatted="false">"公告詳情"</string>
<string name="text_2428420e407fe2e8" formatted="false">"共 "</string>
<string name="text_d977c7568cc5201e" formatted="false">"共 {0} 條 · {1} 條未讀"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"關於"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"關於 CrossDesk"</string>
<string name="text_390a95b65eb33052" formatted="false">"關於,有新版本"</string>
<string name="text_3fd47edce45b3603" formatted="false">"關閉"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"關閉控制欄"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"關閉隱私屏"</string>
<string name="text_7d466b69be42fc43" formatted="false">"兼顧清晰度和流暢度,只在持續壓力下逐步降低解析度。"</string>
<string name="text_b92421822c213c2b" formatted="false">"解析度"</string>
<string name="text_2f9daa828907b93f" formatted="false">"刪除"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"刪除公告"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"刪除記錄"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"刪除這條公告?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"重新整理公告"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"剪貼簿"</string>
<string name="text_4927a53bcc886afb" formatted="false">"載入中…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"載入更多"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"更新提示"</string>
<string name="text_861521c9e107c648" formatted="false">"原生連線初始化失敗"</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"傳送"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"傳送 "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"傳送後,在遠端鍵盤中點選“貼上”。接收的遠端文字可手動複製到本機。"</string>
<string name="text_e7281fbca7af369c" formatted="false">"傳送檔案"</string>
<string name="text_ce6c02d19022e016" formatted="false">"傳送文字"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"取消"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"取消連線"</string>
<string name="text_72814b15a6fa152c" formatted="false">"各元件遵循所列開源許可證,其授予的權利不受本應用普通使用條款限制。檢視原始碼需要網路連線。"</string>
<string name="text_e009c148d1890dac" formatted="false">"合計"</string>
<string name="text_81c92013219dd115" formatted="false">"同意並繼續"</string>
<string name="text_409696774c9998ce" formatted="false">"後臺保持連線,點按返回遠端桌面"</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"後臺保活未能啟動,請保持應用在前臺"</string>
<string name="text_d96a21463557171d" formatted="false">"後臺保活未能啟動,請保持應用在前臺後重新連線"</string>
<string name="text_952f7300de0a7bd9" formatted="false">"後臺連線已結束,請重新連線"</string>
<string name="text_da904f715e1cff87" formatted="false">"後臺連線服務已停止,請重新連線"</string>
<string name="text_0812359e18082071" formatted="false">"後續連線仍會儲存新的預覽圖。"</string>
<string name="text_b9086662b1df1f20" formatted="false">"線上"</string>
<string name="text_39f2f444a042643e" formatted="false">"填寫主機名或 IPv4 地址,不含協議和路徑"</string>
<string name="text_473400815c0f687d" formatted="false">"聲音"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"複製遠端文字"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"始終保持較高解析度,頻寬不足時透過降低幀率來保證畫面清晰度。"</string>
<string name="text_7ff73de85ec16d15" formatted="false">"媒體加密"</string>
<string name="text_22dc6217af77bc37" formatted="false">"字母數字"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"安全儲存或證書初始化失敗,請重試或清除應用資料"</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"完成"</string>
<string name="text_678328f6f02881c7" formatted="false">"完成隱私授權後可檢查更新"</string>
<string name="text_c4361f9abdba4882" formatted="false">"完成隱私授權後登記本機身份"</string>
<string name="text_c29b888e357d727d" formatted="false">"密碼儲存失敗,本次連線仍可使用"</string>
<string name="text_3f31300a23afb390" formatted="false">"密碼將加密儲存在本機 Android Keystore 中"</string>
<string name="text_8d3b052c4c67acda" formatted="false">"對端 ID"</string>
<string name="text_0512084e130984bc" formatted="false">"對端 ID "</string>
<string name="text_c5147d65960196b0" formatted="false">"對端 ID {0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"將刪除本機儲存的所有遠端預覽圖,保留連線記錄和密碼。"</string>
<string name="text_60cc99cc0013972e" formatted="false">"螢幕方向"</string>
<string name="text_9e7294d264afecb7" formatted="false">"展開遠端控制選單"</string>
<string name="text_9343612ee473edd6" formatted="false">"已從通知斷開遠端連線"</string>
<string name="text_9d32476fe83aef60" formatted="false">"已儲存密碼"</string>
<string name="text_1cbd391383283924" formatted="false">"已儲存密碼無法讀取,請重新輸入"</string>
<string name="text_6792b9f341380c68" formatted="false">"已儲存的密碼未能保留,請重新輸入一次"</string>
<string name="text_74d3f0927d465047" formatted="false">"已傳送 Ctrl+Alt+Del"</string>
<string name="text_c28d86acbedba3b6" formatted="false">"已啟用後臺保活,切換應用可繼續保持連線"</string>
<string name="text_506409ec08cb2a79" formatted="false">"已啟用後臺保活,可從連線通知返回或斷開"</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"已授權"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"已讀"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"已連線 "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"已連線伺服器"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"幀率"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"影格率優先"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"平衡"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"應用中的許可檔案缺失或損壞,請重新安裝應用。"</string>
<string name="text_3f2904435942cc0f" formatted="false">"開發版本包含尚未釋出的修改,以下連結為基礎版本原始碼。"</string>
<string name="text_4338afaad52ea445" formatted="false">"啟用儲存"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"開啟隱私屏"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"開源元件"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"開源軟體權利說明"</string>
<string name="text_6e86570183c3cdd0" formatted="false">"當前已是最新版本"</string>
<string name="text_24a52788cf474bf6" formatted="false">"當前遠端不支援直接輸入此文字,請使用支援文字輸入的被控端版本,或透過剪貼簿傳送"</string>
<string name="text_33f25b500697a7d9" formatted="false">"成功連線後,這裡會顯示最近的遠端裝置。"</string>
<string name="text_70341256d7317e7d" formatted="false">"我已閱讀並同意《隱私政策》"</string>
<string name="text_85c4c8fda0461f35" formatted="false">"所選檔案(無法讀取或空間不足)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"找不到該裝置 ID"</string>
<string name="text_97b7b18df07b9787" formatted="false">"指令傳送失敗,請重試"</string>
<string name="text_cb782027ca5986c7" formatted="false">"指令已傳送,等待電腦確認…"</string>
<string name="text_3ca40a586103f6b7" formatted="false">"授權狀態"</string>
<string name="text_6e684586884ebee6" formatted="false">"接收"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"接收 "</string>
<string name="text_d16488b987723161" formatted="false">"控制端解碼器"</string>
<string name="text_6b58198955259066" formatted="false">"撤回"</string>
<string name="text_c52e42ee856825db" formatted="false">"撤回後將停止遠端連線、關閉畫面預覽儲存並清除已有預覽。連線記錄和密碼會保留,重新連線前需再次授權。"</string>
<string name="text_61f4788032ec596b" formatted="false">"撤回聯網授權"</string>
<string name="text_e736e419008ac10a" formatted="false">"撤回聯網授權?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"收起遠端控制選單"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"收起鍵盤"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"收起滑鼠"</string>
<string name="text_5440f7424f2865bf" formatted="false">"資料"</string>
<string name="text_94244f41d0a41378" formatted="false">"檔案儲存失敗,請重試"</string>
<string name="text_2553a785fef2a405" formatted="false">"檔案已儲存"</string>
<string name="text_d573b3f9a810ea04" formatted="false">"檔案選擇已超時,會話已結束"</string>
<string name="text_985a5c380be49f4f" formatted="false">"文字超過 128 KB"</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"斷開"</string>
<string name="text_09b1395f50589327" formatted="false">"斷開後將返回首頁"</string>
<string name="text_a73c0680db0d2e06" formatted="false">"斷開遠端連線?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"中斷連線"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"新版本"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"新版本可用:v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"新版本可用:v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"無未讀公告"</string>
<string name="text_dca2c418ac967366" formatted="false">"無法儲存本地閱讀狀態,請重試。"</string>
<string name="text_5c92da76360056cc" formatted="false">"無法刪除這條公告,請重試。"</string>
<string name="text_615709dd3129313c" formatted="false">"無法發起遠端連線"</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"無法讀取開源許可"</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"顯示器"</string>
<string name="text_ffe21a671190323e" formatted="false">"顯示密碼"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"暫不同意"</string>
<string name="text_6d9a7758610a2432" formatted="false">"暫無公告"</string>
<string name="text_0b290c6600d92f43" formatted="false">"最近連線"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"伺服器"</string>
<string name="text_dfe408e686175095" formatted="false">"伺服器 TLS 證書校驗失敗"</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"伺服器地址"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"伺服器登入失敗,請檢查伺服器地址和網路"</string>
<string name="text_d270868960e68629" formatted="false">"伺服器埠"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"伺服器連線超時,請檢查伺服器地址和網路"</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"未啟用"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"未找到可開啟連結的瀏覽器"</string>
<string name="text_94bc3d40defef3e9" formatted="false">"未授權"</string>
<string name="text_4d0680f9efaef147" formatted="false">"未讀"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"未連線伺服器 · 點選重試"</string>
<string name="text_7eea7966c0b746c5" formatted="false">"本應用使用 OpenFEC 提供前向糾錯功能,遵循 CeCILL-C 1.0 許可。該許可規定了有限保證及責任限制,詳情見許可全文。"</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"本機 ID "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"本機 ID {0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"本版本原始碼與構建說明"</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"本版本原始碼資訊暫不可用。"</string>
<string name="text_f0e274aa130e08a2" formatted="false">"鬆開重新整理"</string>
<string name="text_47077144715b7074" formatted="false">"構建說明"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"構建配方與專案補丁"</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"檢視原始碼"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"檢查失敗,請稍後重試"</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"檢查更新"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"正在關閉隱私屏…"</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"正在建立安全連線…"</string>
<string name="text_0da575e3c92de73a" formatted="false">"正在開啟隱私屏…"</string>
<string name="text_91834aebecabcbc3" formatted="false">"正在檢查更新…"</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"正在清除…"</string>
<string name="text_0e6336c8fe918730" formatted="false">"正在獲取本機 ID…"</string>
<string name="text_faebd20c2801fd0f" formatted="false">"正在讀取所選檔案…"</string>
<string name="text_694186911ae3e4da" formatted="false">"正在連線"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"正在連線信令伺服器…"</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"正在連線伺服器…"</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"正在重連伺服器…"</string>
<string name="text_4e4fce649fc00238" formatted="false">"此裝置沒有可用的檔案選擇器"</string>
<string name="text_bce2377283c2455a" formatted="false">"清除"</string>
<string name="text_ee0e76b8507212de" formatted="false">"清除預覽圖"</string>
<string name="text_ecc809e44637de18" formatted="false">"清除預覽圖?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"原始碼與構建說明"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"原始碼版本"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"版本"</string>
<string name="text_16410ee9663fad10" formatted="false">"版權與許可宣告"</string>
<string name="text_11de77e1e41e680f" formatted="false">"電腦鍵盤"</string>
<string name="text_20f57900234debd7" formatted="false">"畫質優先"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"畫面偏好"</string>
<string name="text_82a5600c470179fa" formatted="false">"畫面延遲"</string>
<string name="text_33b458faaf967e15" formatted="false">"畫面設定"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"畫面質量"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"畫面採集幀率"</string>
<string name="text_66062971234f422d" formatted="false">"相對位置"</string>
<string name="text_c421dc051bea3f11" formatted="false">"相對滑鼠"</string>
<string name="text_de32e20193ad1042" formatted="false">"知道了"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"確定"</string>
<string name="text_36f33adaf0942634" formatted="false">"確認"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"確認連線"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"離線"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"空白鍵"</string>
<string name="text_471a579e8d87faac" formatted="false">"埠範圍為 1–65535"</string>
<string name="text_c7d0c77862e32b52" formatted="false">"符號功能"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"等待電腦返回隱私屏狀態;舊版電腦端可能不支援"</string>
<string name="text_5ee880e2524a983a" formatted="false">"等待遠端螢幕資訊"</string>
<string name="text_674c723a85ee7f57" formatted="false">"等待遠端畫面…"</string>
<string name="text_119f0483da4756c4" formatted="false">"等待隱私授權"</string>
<string name="text_7916071c1a8ee458" formatted="false">"系統鍵盤"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"絕對位置"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"絕對滑鼠"</string>
<string name="text_872428a7210d5e0c" formatted="false">"網路狀態"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"自定義"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"虛擬滑鼠"</string>
<string name="text_62c199f37bf95869" formatted="false">"被控端已透過組合鍵解除隱私屏。點選“鎖定”將重新開啟隱私屏,並禁止本機使用組合鍵解鎖。"</string>
<string name="text_a569ce729c1d6e19" formatted="false">"被控端版本過低,請升級到最新版本。"</string>
<string name="text_d814476826827b78" formatted="false">"被控端編碼器"</string>
<string name="text_488402883e27f6f0" formatted="false">"要傳送到遠端的文字"</string>
<string name="text_c20f7618d330a854" formatted="false">"影片"</string>
<string name="text_310c625425f014d8" formatted="false">"許可"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"許可與版權宣告"</string>
<string name="text_e24134b643a17975" formatted="false">"許可與版權宣告可離線查閱。"</string>
<string name="text_5e7628f043ce71d0" formatted="false">"裝置身份儲存失敗,請檢查安全儲存"</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"裝置身份及選擇儲存的訪問密碼使用 Android Keystore 加密。連線記錄僅儲存在本機,不納入裝置備份。剪貼簿內容在結束會話時清除。離開應用後透過畫中畫或連線通知保持會話,可在應用或通知中斷開。"</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"設定"</string>
<string name="text_4f8771952222a011" formatted="false">"設定,有新版本"</string>
<string name="text_f4f712d5be91721a" formatted="false">"訪問密碼"</string>
<string name="text_dd6c465847af1b60" formatted="false">"訪問密碼錯誤,請重新輸入"</string>
<string name="text_defba1d2989bfab4" formatted="false">"證書校驗失敗 · 點選重試"</string>
<string name="text_44f3d4d52347095a" formatted="false">"請先完成聯網授權"</string>
<string name="text_2a07ca002bc5093d" formatted="false">"請在遠端啟用 CrossDesk 系統服務後重試"</string>
<string name="text_990fd23c62de5ccf" formatted="false">"請求超時,尚未確認電腦的隱私屏狀態"</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"請等待當前檔案傳送完成"</string>
<string name="text_9305d9d72b06110d" formatted="false">"請輸入 1–65535 範圍內的埠"</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"請輸入 9 位裝置 ID"</string>
<string name="text_285a0714894d2899" formatted="false">"請輸入有效訪問密碼"</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"調整失敗,請重試。"</string>
<string name="text_a762e7e650e285fa" formatted="false">"軟體許可"</string>
<string name="text_572cf45ba43634b3" formatted="false">"返回"</string>
<string name="text_7565f94031582c7c" formatted="false">"返回控制欄"</string>
<string name="text_55113b6925c362fd" formatted="false">"還沒有連線記錄"</string>
<string name="text_3aa527b2106cc987" formatted="false">"這條公告已更新或刪除,請返回公告列表檢視。"</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"遠端任務已關閉"</string>
<string name="text_c255841f68096d3b" formatted="false">"遠端桌面"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"遠端桌面觸控區域"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"遠端桌面:輕觸點選,拖動移動,長按右鍵,雙指捏合縮放,放大後雙指拖動畫面,原始比例下雙指上下滑動滾動"</string>
<string name="text_3535087994d050de" formatted="false">"遠端電腦"</string>
<string name="text_ed2e03072ce131de" formatted="false">"遠端視窗已關閉,或後臺保活未能啟動,會話已結束"</string>
<string name="text_678b783fb578172b" formatted="false">"遠端連線"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"遠端密碼錯誤"</string>
<string name="text_a2ce499295e358ae" formatted="false">"遠端已關閉連線"</string>
<string name="text_176e117df3ace734" formatted="false">"遠端當前不可用"</string>
<string name="text_d2c0694243f93249" formatted="false">"遠端訪問密碼已失效,請重新連線"</string>
<string name="text_a5574109f0208e89" formatted="false">"連線"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"連線 "</string>
<string name="text_89c4766afc85125f" formatted="false">"連線失敗"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"連線失敗 · 點選重試"</string>
<string name="text_2ab7d89557e779ab" formatted="false">"連線已斷開"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"連線已結束"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"連線延時(RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"連線建立後可調整,需被控端支援。"</string>
<string name="text_2123a9ed718f2c40" formatted="false">"連線建立後鎖定橫屏"</string>
<string name="text_f741e0536210eafa" formatted="false">"連線提示"</string>
<string name="text_485a26050ce57431" formatted="false">"連線方式"</string>
<string name="text_5a048351a77fe88d" formatted="false">"連線伺服器"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"連線伺服器後可獲取最新公告。"</string>
<string name="text_32cd27b5a72515f3" formatted="false">"連線超時,請檢查裝置 ID、伺服器及遠端線上狀態"</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"連線遠端桌面"</string>
<string name="text_d5407b4f3c666681" formatted="false">"退格"</string>
<string name="text_e02926784fcedb91" formatted="false">"通知公告"</string>
<string name="text_465a092680d132b3" formatted="false">"部分預覽圖未能清除,請重試。"</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"重試"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"重試儲存閱讀狀態"</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"鎖定"</string>
<string name="text_864200bbb241e067" formatted="false">"鍵盤"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"閱讀並授權"</string>
<string name="text_86651d17a401c55b" formatted="false">"隱私"</string>
<string name="text_26c6e011e343708c" formatted="false">"隱私與授權"</string>
<string name="text_6cad42fd3da85585" formatted="false">"隱私屏"</string>
<string name="text_e157fafb76aad954" formatted="false">"隱私屏已關閉"</string>
<string name="text_bbef0eac0c04f214" formatted="false">"隱私屏已開啟"</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"隱私屏已被解鎖"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"隱私屏異常"</string>
<string name="text_598a2747dbd21dd5" formatted="false">"隱私屏暫不可用"</string>
<string name="text_4d59be3c75189032" formatted="false">"隱私屏狀態已過期,等待電腦確認"</string>
<string name="text_1674532c70ed1db2" formatted="false">"隱私屏狀態待確認"</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"隱私政策"</string>
<string name="text_554e957e8e3c00af" formatted="false">"隱私政策暫時無法讀取,請拒絕聯網並重新安裝應用。"</string>
<string name="text_7db2e189ecb01065" formatted="false">"靜音"</string>
<string name="text_296c632ec857a0ba" formatted="false">"音訊"</string>
<string name="text_0cadb49096a382e0" formatted="false">"專案主頁"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"預覽可能包含遠端電腦上的個人或工作資訊。開啟後,每次連線會自動將一張畫面儲存在本機並顯示在最近連線中。你可以隨時關閉並清除。"</string>
<string name="text_b1c27820fec23edb" formatted="false">"高"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"預設"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"預設關閉。開啟後,手機連線時進入並保持橫屏;關閉時跟隨裝置方向。平板和多視窗不受影響。"</string>
<string name="text_1184a97edd8e1646" formatted="false">"滑鼠右鍵,按住並拖動,鬆開結束拖拽"</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"滑鼠左鍵,按住並拖動,鬆開結束拖拽"</string>
<string name="text_269f39a07b4a4e01" formatted="false">"滑鼠控制"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"滑鼠滾輪,上下滑動,可滑出按鍵範圍"</string>
<string name="text_9aa238fc6121dede" formatted="false">",丟包率 "</string>
<string name="text_0b5d16c21095fe10" formatted="false">",傳送 "</string>
<string name="text_ba9a792d063a8c86" formatted="false">",已儲存密碼"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">",接收 "</string>
<string name="text_42237b5ec70c26f3" formatted="false">",點選重試"</string>
</resources>
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" · Abgeschlossen"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" Übertragung fehlgeschlagen"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" Geräte"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" Artikel · "</string>
<string name="text_2c894057fc526576" formatted="false">" ungelesene Artikel"</string>
<string name="text_d0ac753c6681e578" formatted="false">"CrossDesk Remote-Verbindung"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL Version 3"</string>
<string name="text_5ae7dc4079223967" formatted="false">"OpenFEC-Hinweis"</string>
<string name="text_69ad29820a259526" formatted="false">"OpenFEC Lizenz und Quelle"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"P2P-Direktverbindung"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRTP enabled"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"TURN-Relais"</string>
<string name="text_ebede975edd52aee" formatted="false">"{0}-Übertragung fehlgeschlagen"</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"Geräte: {0}"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"{0} ungelesene Elemente"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0}, Empfangen von {1}, Senden von {2}, Paketverlustrate {3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● Verbunden"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"Zum Aktualisieren nach unten ziehen"</string>
<string name="text_37772187ab854191" formatted="false">"Verlustrate"</string>
<string name="text_a567bdaa11367f26" formatted="false">"Mittel"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"Priorisiert die Aufrechterhaltung eines flüssigen Bildes und reduziert die Auflösung stärker, wenn die Bandbreite oder der Codierungsdruck hoch ist."</string>
<string name="text_aa9e366f68d3d097" formatted="false">"Niedrig"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"Verwendung eines benutzerdefinierten Servers"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"Passwort speichern"</string>
<string name="text_10019cef593819ed" formatted="false">"Empfangene Datei speichern"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"Remote-Bildschirmvorschauen speichern"</string>
<string name="text_0f197074373e38fd" formatted="false">"Remote-Bildschirmvorschau speichern?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"Die Signalverbindung ist unterbrochen. Bitte stellen Sie die Verbindung wieder her."</string>
<string name="text_789f22a8b1b34797" formatted="false">"Das Laden der Ankündigung ist fehlgeschlagen. Bitte versuchen Sie es erneut."</string>
<string name="text_00554b17c432937e" formatted="false">"Einzelheiten zur Ankündigung"</string>
<string name="text_2428420e407fe2e8" formatted="false">"Insgesamt "</string>
<string name="text_d977c7568cc5201e" formatted="false">"Insgesamt {0} Artikel ·{1} ungelesene Artikel"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"Ungefähr"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"Über CrossDesk"</string>
<string name="text_390a95b65eb33052" formatted="false">"Ungefähr, es gibt eine neue Version"</string>
<string name="text_3fd47edce45b3603" formatted="false">"Schließen"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"Bedienbügel schließen"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"Privatsphäre-Bildschirm deaktivieren"</string>
<string name="text_7d466b69be42fc43" formatted="false">"Gleicht Klarheit und Glätte aus und verringert die Auflösung bei konstantem Druck nur allmählich."</string>
<string name="text_b92421822c213c2b" formatted="false">"Auflösung"</string>
<string name="text_2f9daa828907b93f" formatted="false">"Löschen"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"Ansage löschen"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"Datensatz löschen"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"Diese Ankündigung löschen?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"Ankündigung aktualisieren"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"Zwischenablage"</string>
<string name="text_4927a53bcc886afb" formatted="false">"Laden…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"Mehr laden"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"Update empfohlen"</string>
<string name="text_861521c9e107c648" formatted="false">"Die Initialisierung der nativen Verbindung ist fehlgeschlagen"</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"Senden"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"Senden "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"Klicken Sie nach dem Senden auf der Remote-Tastatur auf „Einfügen“. Der empfangene Remote-Text kann manuell auf den lokalen Computer kopiert werden."</string>
<string name="text_e7281fbca7af369c" formatted="false">"Datei senden"</string>
<string name="text_ce6c02d19022e016" formatted="false">"Text senden"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"Abbrechen"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"Verbindung abbrechen"</string>
<string name="text_72814b15a6fa152c" formatted="false">"Jede Komponente unterliegt der aufgeführten Open-Source-Lizenz und die gewährten Rechte unterliegen nicht den allgemeinen Nutzungsbedingungen dieser Anwendung. Für die Anzeige des Quellcodes ist eine Internetverbindung erforderlich."</string>
<string name="text_e009c148d1890dac" formatted="false">"Insgesamt"</string>
<string name="text_81c92013219dd115" formatted="false">"Zustimmen und fortfahren"</string>
<string name="text_409696774c9998ce" formatted="false">"Bleiben Sie im Hintergrund verbunden. Klicken Sie, um zum Remote-Desktop zurückzukehren"</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"Hintergrund-Keepalive konnte nicht gestartet werden. Bitte lassen Sie die Anwendung im Vordergrund"</string>
<string name="text_d96a21463557171d" formatted="false">"Hintergrund-Keepalive konnte nicht gestartet werden. Bitte lassen Sie die Anwendung im Vordergrund und stellen Sie die Verbindung erneut her."</string>
<string name="text_952f7300de0a7bd9" formatted="false">"Die Hintergrundverbindung wurde beendet. Bitte stellen Sie die Verbindung wieder her."</string>
<string name="text_da904f715e1cff87" formatted="false">"Der Hintergrundverbindungsdienst wurde beendet. Bitte stellen Sie die Verbindung wieder her."</string>
<string name="text_0812359e18082071" formatted="false">"Neue Vorschauen werden bei nachfolgenden Verbindungen weiterhin gespeichert."</string>
<string name="text_b9086662b1df1f20" formatted="false">"Online"</string>
<string name="text_39f2f444a042643e" formatted="false">"Geben Sie den Hostnamen oder die IPv4-Adresse ein, ohne Protokoll und Pfad"</string>
<string name="text_473400815c0f687d" formatted="false">"Ton"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"Remote-Text kopieren"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"Behalten Sie immer eine höhere Auflösung bei und reduzieren Sie die Bildrate, um die Bildschärfe zu gewährleisten, wenn die Bandbreite nicht ausreicht."</string>
<string name="text_7ff73de85ec16d15" formatted="false">"Medienverschlüsselung"</string>
<string name="text_22dc6217af77bc37" formatted="false">"Buchstaben &amp; Zahlen"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"Der sichere Speicher oder die Initialisierung des Zertifikats ist fehlgeschlagen. Bitte versuchen Sie es erneut oder löschen Sie die Anwendungsdaten"</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"Fertig"</string>
<string name="text_678328f6f02881c7" formatted="false">"Sie können nach Abschluss der Datenschutzautorisierung nach Updates suchen"</string>
<string name="text_c4361f9abdba4882" formatted="false">"Registrieren Sie die lokale Identität, nachdem Sie die Datenschutzautorisierung abgeschlossen haben"</string>
<string name="text_c29b888e357d727d" formatted="false">"Das Speichern des Passworts ist fehlgeschlagen. Diese Verbindung kann weiterhin verwendet werden"</string>
<string name="text_3f31300a23afb390" formatted="false">"Passwörter werden verschlüsselt im nativen Android Keystore gespeichert"</string>
<string name="text_8d3b052c4c67acda" formatted="false">"Remote-ID"</string>
<string name="text_0512084e130984bc" formatted="false">"Peer-ID "</string>
<string name="text_c5147d65960196b0" formatted="false">"Peer-ID {0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"Alle auf diesem Computer gespeicherten Remote-Vorschauen werden gelöscht und die Verbindungsdatensätze und Passwörter bleiben erhalten."</string>
<string name="text_60cc99cc0013972e" formatted="false">"Bildschirmausrichtung"</string>
<string name="text_9e7294d264afecb7" formatted="false">"Fernbedienungsmenü erweitern"</string>
<string name="text_9343612ee473edd6" formatted="false">"Remote-Verbindung wegen Benachrichtigung getrennt"</string>
<string name="text_9d32476fe83aef60" formatted="false">"Passwort gespeichert"</string>
<string name="text_1cbd391383283924" formatted="false">"Das gespeicherte Passwort kann nicht gelesen werden, bitte geben Sie es erneut ein."</string>
<string name="text_6792b9f341380c68" formatted="false">"Das gespeicherte Passwort kann nicht beibehalten werden, bitte geben Sie es erneut ein."</string>
<string name="text_74d3f0927d465047" formatted="false">"Ctrl+Alt+Del gesendet"</string>
<string name="text_c28d86acbedba3b6" formatted="false">"Hintergrund-Keep-Alive ist aktiviert, wechselnde Anwendungen können die Verbindung weiterhin aufrechterhalten"</string>
<string name="text_506409ec08cb2a79" formatted="false">"Hintergrund-Keepalive aktiviert, um Verbindungsbenachrichtigungen zurückzugeben oder zu trennen"</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"Zugegeben"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"Lesen"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"Verbunden "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"Mit Server verbunden"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"Bildrate"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"Flüssig"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"Balance"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"Die Lizenzdatei in der Anwendung fehlt oder ist beschädigt. Bitte installieren Sie die Anwendung neu."</string>
<string name="text_3f2904435942cc0f" formatted="false">"Die Entwicklungsversion enthält Änderungen, die noch nicht veröffentlicht wurden. Der folgende Link ist der Quellcode der Basisversion."</string>
<string name="text_4338afaad52ea445" formatted="false">"Speichern aktivieren"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"Privatsphärenschutz aktivieren"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"Open-Source-Komponenten"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"Beschreibung der Open-Source-Softwarerechte"</string>
<string name="text_6e86570183c3cdd0" formatted="false">"Dies ist derzeit die neueste Version"</string>
<string name="text_24a52788cf474bf6" formatted="false">"Derzeit unterstützt die Gegenstelle keine direkte Eingabe dieses Textes. Bitte verwenden Sie eine Version des gesteuerten Endes, die die Texteingabe unterstützt, oder senden Sie sie über die Zwischenablage."</string>
<string name="text_33f25b500697a7d9" formatted="false">"Nach erfolgreicher Verbindung wird hier das nächstgelegene Remote-Gerät angezeigt."</string>
<string name="text_70341256d7317e7d" formatted="false">"Ich habe die Datenschutzerklärung gelesen und bin damit einverstanden"</string>
<string name="text_85c4c8fda0461f35" formatted="false">"Ausgewählte Datei (kann nicht gelesen werden oder nicht genügend Speicherplatz)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"Die Geräte-ID wurde nicht gefunden"</string>
<string name="text_97b7b18df07b9787" formatted="false">"Das Senden des Befehls ist fehlgeschlagen. Bitte versuchen Sie es erneut"</string>
<string name="text_cb782027ca5986c7" formatted="false">"Der Befehl wurde gesendet und wartet auf die Bestätigung des Computers ..."</string>
<string name="text_3ca40a586103f6b7" formatted="false">"Autorisierungsstatus"</string>
<string name="text_6e684586884ebee6" formatted="false">"Empfangen"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"erhalten "</string>
<string name="text_d16488b987723161" formatted="false">"Lokaler Decoder"</string>
<string name="text_6b58198955259066" formatted="false">"Widerrufen"</string>
<string name="text_c52e42ee856825db" formatted="false">"Nach dem Abheben wird die Fernverbindung beendet, die Bildschirmvorschau geschlossen und die bestehende Vorschau gelöscht. Der Verbindungsdatensatz und das Passwort bleiben erhalten und vor der erneuten Verbindung ist eine erneute Autorisierung erforderlich."</string>
<string name="text_61f4788032ec596b" formatted="false">"Netzwerkberechtigung entziehen"</string>
<string name="text_e736e419008ac10a" formatted="false">"Netzwerkberechtigung entziehen?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"Fernbedienungsmenü einklappen"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"Tastatur ausblenden"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"Maus ausblenden"</string>
<string name="text_5440f7424f2865bf" formatted="false">"Daten"</string>
<string name="text_94244f41d0a41378" formatted="false">"Das Speichern der Datei ist fehlgeschlagen. Bitte versuchen Sie es erneut"</string>
<string name="text_2553a785fef2a405" formatted="false">"Datei gespeichert"</string>
<string name="text_d573b3f9a810ea04" formatted="false">"Zeitüberschreitung bei der Dateiauswahl und Sitzung beendet"</string>
<string name="text_985a5c380be49f4f" formatted="false">"Der Text ist größer als 128 KB"</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"Trennen"</string>
<string name="text_09b1395f50589327" formatted="false">"Kehrt nach dem Trennen der Verbindung zur Startseite zurück"</string>
<string name="text_a73c0680db0d2e06" formatted="false">"Remote-Verbindung trennen?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"Trennen"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"Neue Version"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"Neue Version verfügbar: v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"Neue Version verfügbar: v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"Keine ungelesenen Ankündigungen"</string>
<string name="text_dca2c418ac967366" formatted="false">"Lesestatus kann nicht lokal gespeichert werden. Bitte versuchen Sie es erneut."</string>
<string name="text_5c92da76360056cc" formatted="false">"Diese Ankündigung kann nicht gelöscht werden. Bitte versuchen Sie es erneut."</string>
<string name="text_615709dd3129313c" formatted="false">"Remote-Verbindung konnte nicht initiiert werden"</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"Open-Source-Lizenz kann nicht gelesen werden"</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"Bildschirm"</string>
<string name="text_ffe21a671190323e" formatted="false">"Passwort anzeigen"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"Jetzt nicht"</string>
<string name="text_6d9a7758610a2432" formatted="false">"Keine Ankündigungen"</string>
<string name="text_0b290c6600d92f43" formatted="false">"Letzte Verbindungen"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"Server"</string>
<string name="text_dfe408e686175095" formatted="false">"Server TLS certificate-Überprüfung fehlgeschlagen"</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"Serveradresse"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"Die Serveranmeldung ist fehlgeschlagen. Bitte überprüfen Sie die Serveradresse und das Netzwerk"</string>
<string name="text_d270868960e68629" formatted="false">"Server-Port"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"Zeitüberschreitung bei der Serververbindung. Bitte überprüfen Sie die Serveradresse und das Netzwerk"</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"Nicht aktiviert"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"Es wurde kein Browser zum Öffnen des Links gefunden"</string>
<string name="text_94bc3d40defef3e9" formatted="false">"Nicht autorisiert"</string>
<string name="text_4d0680f9efaef147" formatted="false">"Ungelesen"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"Server nicht verbunden · Klicken Sie, um es erneut zu versuchen"</string>
<string name="text_7eea7966c0b746c5" formatted="false">"Diese Anwendung verwendet OpenFEC zur Vorwärtsfehlerkorrektur unterCeCILL-C1.0, was eingeschränkte Garantien und Haftungsbeschränkungen bietet. Einzelheiten finden Sie in der vollständigen Lizenz."</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"Lokale ID "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"Lokale ID {0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"Quellcode und Build-Anweisungen für diese Version"</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"Die Quellrevision ist für diesen Build nicht verfügbar."</string>
<string name="text_f0e274aa130e08a2" formatted="false">"Zum Aktualisieren loslassen"</string>
<string name="text_47077144715b7074" formatted="false">"Bauanleitung"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"Erstellen Sie Rezepte und Projekt-Patches"</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"Quellcode anzeigen"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"Prüfung fehlgeschlagen. Bitte versuchen Sie es später noch einmal."</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"Suchen Sie nach Updates"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"Sichtschutz wird geschlossen…"</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"Sichere Verbindung wird hergestellt…"</string>
<string name="text_0da575e3c92de73a" formatted="false">"Privatsphärenschutz einschalten..."</string>
<string name="text_91834aebecabcbc3" formatted="false">"Suche nach Updates…"</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"Löschen..."</string>
<string name="text_0e6336c8fe918730" formatted="false">"Lokale ID erhalten..."</string>
<string name="text_faebd20c2801fd0f" formatted="false">"Ausgewählte Datei wird gelesen..."</string>
<string name="text_694186911ae3e4da" formatted="false">"Verbinden"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"Verbindung zum Signalisierungsserver wird hergestellt..."</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"Verbindung zum Server herstellen..."</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"Verbindung zum Server wird wiederhergestellt..."</string>
<string name="text_4e4fce649fc00238" formatted="false">"Für dieses Gerät ist keine Dateiauswahl verfügbar"</string>
<string name="text_bce2377283c2455a" formatted="false">"Klar"</string>
<string name="text_ee0e76b8507212de" formatted="false">"Vorschaubilder löschen"</string>
<string name="text_ecc809e44637de18" formatted="false">"Vorschaubilder löschen?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"Quellcode und Build-Anweisungen"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"Quellcode-Version"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"Version"</string>
<string name="text_16410ee9663fad10" formatted="false">"Urheberrechts- und Lizenzerklärung"</string>
<string name="text_11de77e1e41e680f" formatted="false">"Computertastatur"</string>
<string name="text_20f57900234debd7" formatted="false">"Qualität"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"Videopräferenz"</string>
<string name="text_82a5600c470179fa" formatted="false">"Videolatenz"</string>
<string name="text_33b458faaf967e15" formatted="false">"Videoeinstellungen"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"Videoqualität"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"Bildrate der Videoaufnahme"</string>
<string name="text_66062971234f422d" formatted="false">"Touchpad"</string>
<string name="text_c421dc051bea3f11" formatted="false">"Relative Maus"</string>
<string name="text_de32e20193ad1042" formatted="false">"Verstanden"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"Okay"</string>
<string name="text_36f33adaf0942634" formatted="false">"Bestätigen"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"Verbindung bestätigen"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"Offline"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"Leertaste"</string>
<string name="text_471a579e8d87faac" formatted="false">"Der Portbereich liegt zwischen 1 und 65535"</string>
<string name="text_c7d0c77862e32b52" formatted="false">"Symbole &amp; Tasten"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"Warten Sie, bis der Computer in den Datenschutzstatus zurückkehrt. Ältere Computer unterstützen dies möglicherweise nicht."</string>
<string name="text_5ee880e2524a983a" formatted="false">"Warten auf Remote-Bildschirminformationen"</string>
<string name="text_674c723a85ee7f57" formatted="false">"Warten auf den Remote-Bildschirm ..."</string>
<string name="text_119f0483da4756c4" formatted="false">"Datenschutzzustimmung ausstehend"</string>
<string name="text_7916071c1a8ee458" formatted="false">"Systemtastatur"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"Direkte Berührung"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"Absolute Maus"</string>
<string name="text_872428a7210d5e0c" formatted="false">"Netzwerk"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"Benutzerdefiniert"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"Virtuelle Maus"</string>
<string name="text_62c199f37bf95869" formatted="false">"Der Remote-Datenschutzbildschirm wurde über die lokale Verknüpfung entsperrt. Sperren Sie es erneut, um die Privatsphäre wiederherzustellen und das Entsperren lokaler Verknüpfungen zu deaktivieren."</string>
<string name="text_a569ce729c1d6e19" formatted="false">"Die kontrollierte Version ist zu niedrig, bitte aktualisieren Sie auf die neueste Version."</string>
<string name="text_d814476826827b78" formatted="false">"Remote-Encoder"</string>
<string name="text_488402883e27f6f0" formatted="false">"Text, der an die Gegenstelle gesendet werden soll"</string>
<string name="text_c20f7618d330a854" formatted="false">"Video"</string>
<string name="text_310c625425f014d8" formatted="false">"Lizenz"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"Lizenzen und Urheberrechtshinweise"</string>
<string name="text_e24134b643a17975" formatted="false">"Die Lizenz- und Urheberrechtshinweise sind offline verfügbar."</string>
<string name="text_5e7628f043ce71d0" formatted="false">"Die Geräteidentität konnte nicht gespeichert werden. Bitte überprüfen Sie den sicheren Speicher"</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"Geräteidentität und ausgewählte gespeicherte Zugangskennwörter werden mit Android Keystore verschlüsselt. Verbindungsdatensätze werden nur lokal gespeichert und sind nicht in der Gerätesicherung enthalten. Der Inhalt der Zwischenablage wird gelöscht, wenn die Sitzung beendet wird. Bleiben Sie mit Bild-in-Bild- oder verbundenen Benachrichtigungen in der Sitzung, nachdem Sie die App verlassen haben, und trennen Sie die Verbindung innerhalb der App oder der Benachrichtigungen."</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"Einstellungen"</string>
<string name="text_4f8771952222a011" formatted="false">"Einstellungen, es gibt eine neue Version"</string>
<string name="text_f4f712d5be91721a" formatted="false">"Zugangspasswort"</string>
<string name="text_dd6c465847af1b60" formatted="false">"Das Zugangspasswort ist falsch, bitte geben Sie es erneut ein."</string>
<string name="text_defba1d2989bfab4" formatted="false">"Zertifikatsüberprüfung fehlgeschlagen · Klicken Sie, um es erneut zu versuchen"</string>
<string name="text_44f3d4d52347095a" formatted="false">"Bitte füllen Sie zunächst die Online-Autorisierung aus"</string>
<string name="text_2a07ca002bc5093d" formatted="false">"Bitte aktivieren Sie den CrossDesk-Systemdienst auf der Gegenseite und versuchen Sie es erneut"</string>
<string name="text_990fd23c62de5ccf" formatted="false">"Die Anfrage ist abgelaufen und der Datenschutzstatus des Computers wurde nicht bestätigt."</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"Bitte warten Sie, bis die aktuelle Datei gesendet wird."</string>
<string name="text_9305d9d72b06110d" formatted="false">"Bitte geben Sie einen Port im Bereich 1–65535 ein"</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"Geben Sie eine 9-stellige Geräte-ID ein"</string>
<string name="text_285a0714894d2899" formatted="false">"Bitte geben Sie ein gültiges Zugangspasswort ein"</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"Einstellungen konnten nicht angewendet werden. Versuchen Sie es erneut."</string>
<string name="text_a762e7e650e285fa" formatted="false">"Softwarelizenz"</string>
<string name="text_572cf45ba43634b3" formatted="false">"Zurück"</string>
<string name="text_7565f94031582c7c" formatted="false">"Zurück zur Steuerleiste"</string>
<string name="text_55113b6925c362fd" formatted="false">"Noch kein Verbindungsdatensatz"</string>
<string name="text_3aa527b2106cc987" formatted="false">"Diese Ankündigung wurde aktualisiert oder gelöscht. Bitte kehren Sie zur Ankündigungsliste zurück, um sie anzuzeigen."</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"Remote-Aufgabe ist geschlossen"</string>
<string name="text_c255841f68096d3b" formatted="false">"Remotedesktop"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"Remote-Desktop-Touchbereich"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"Remote-Desktop: Berühren und klicken, zum Verschieben ziehen, die rechte Taste gedrückt halten, zum Zoomen zusammenziehen, hineinzoomen und den Bildschirm mit zwei Fingern ziehen, mit zwei Fingern nach oben und unten streichen, um im Originalmaßstab zu scrollen"</string>
<string name="text_3535087994d050de" formatted="false">"Remote-Computer"</string>
<string name="text_ed2e03072ce131de" formatted="false">"Das Remote-Fenster wurde geschlossen oder der Hintergrund-Keepalive konnte nicht gestartet werden und die Sitzung wurde beendet."</string>
<string name="text_678b783fb578172b" formatted="false">"Remote-Verbindung"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"Falsches Remote-Passwort"</string>
<string name="text_a2ce499295e358ae" formatted="false">"Die Gegenstelle hat die Verbindung geschlossen"</string>
<string name="text_176e117df3ace734" formatted="false">"Die Gegenstelle ist derzeit nicht verfügbar"</string>
<string name="text_d2c0694243f93249" formatted="false">"Das Passwort für den Fernzugriff ist abgelaufen. Bitte verbinden Sie sich erneut."</string>
<string name="text_a5574109f0208e89" formatted="false">"Verbinden"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"Verbindung "</string>
<string name="text_89c4766afc85125f" formatted="false">"Verbindung fehlgeschlagen"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"Verbindung fehlgeschlagen · Klicken Sie, um es erneut zu versuchen"</string>
<string name="text_2ab7d89557e779ab" formatted="false">"Verbindung verloren"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"Verbindung beendet"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"Verbindungsverzögerung (RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"Verfügbar bei Verbindung mit einem unterstützten Remote-Gerät."</string>
<string name="text_2123a9ed718f2c40" formatted="false">"Querformatbildschirm sperren, nachdem die Verbindung hergestellt wurde"</string>
<string name="text_f741e0536210eafa" formatted="false">"Verbindungsaufforderung"</string>
<string name="text_485a26050ce57431" formatted="false">"Verbindungsmethode"</string>
<string name="text_5a048351a77fe88d" formatted="false">"Mit dem Server verbinden"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"Stellen Sie eine Verbindung zum Server her, um die neuesten Ankündigungen zu erhalten."</string>
<string name="text_32cd27b5a72515f3" formatted="false">"Zeitüberschreitung bei der Verbindung. Bitte überprüfen Sie die Geräte-ID, den Server und den Remote-Online-Status"</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"Mit Remote-Desktop verbinden"</string>
<string name="text_d5407b4f3c666681" formatted="false">"Rücktaste"</string>
<string name="text_e02926784fcedb91" formatted="false">"Mitteilungen"</string>
<string name="text_465a092680d132b3" formatted="false">"Einige Vorschauen konnten nicht gelöscht werden. Bitte versuchen Sie es erneut."</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"Versuchen Sie es erneut"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"Versuchen Sie erneut, den Lesestatus zu speichern"</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"Sperren"</string>
<string name="text_864200bbb241e067" formatted="false">"Tastatur"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"Lesen und autorisieren"</string>
<string name="text_86651d17a401c55b" formatted="false">"Datenschutz"</string>
<string name="text_26c6e011e343708c" formatted="false">"Datenschutz und Autorisierung"</string>
<string name="text_6cad42fd3da85585" formatted="false">"Sichtschutz"</string>
<string name="text_e157fafb76aad954" formatted="false">"Sichtschutz aus"</string>
<string name="text_bbef0eac0c04f214" formatted="false">"Sichtschutz an"</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"Datenschutzbildschirm entsperrt"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"Anomalie des Sichtschutzbildschirms"</string>
<string name="text_598a2747dbd21dd5" formatted="false">"Der Datenschutzbildschirm ist vorübergehend nicht verfügbar"</string>
<string name="text_4d59be3c75189032" formatted="false">"Der Status des Datenschutzbildschirms ist abgelaufen und wartet auf Computerbestätigung"</string>
<string name="text_1674532c70ed1db2" formatted="false">"Status des Datenschutzbildschirms muss bestätigt werden"</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"Datenschutzrichtlinie"</string>
<string name="text_554e957e8e3c00af" formatted="false">"Die Datenschutzerklärung kann vorübergehend nicht gelesen werden. Bitte verweigern Sie die Verbindung zum Internet und installieren Sie die Anwendung neu."</string>
<string name="text_7db2e189ecb01065" formatted="false">"Stumm"</string>
<string name="text_296c632ec857a0ba" formatted="false">"Audio"</string>
<string name="text_0cadb49096a382e0" formatted="false">"Projektwebsite"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"Vorschauen können persönliche oder geschäftliche Informationen vom Remote-Computer enthalten. Nach dem Einschalten speichert jede Verbindung automatisch ein Bild auf dem Gerät und zeigt es in den letzten Verbindungen an. Sie können jederzeit schließen und löschen."</string>
<string name="text_b1c27820fec23edb" formatted="false">"Hoch"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"Standard"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"Standardmäßig deaktiviert. Wenn es eingeschaltet ist, wird der Bildschirm angezeigt und bleibt im Querformat, wenn das Telefon angeschlossen ist. Wenn es ausgeschaltet ist, folgt es der Richtung des Geräts. Tablets und Multi-Windows sind nicht betroffen."</string>
<string name="text_1184a97edd8e1646" formatted="false">"Rechte Maustaste, drücken und ziehen, loslassen, um das Ziehen zu beenden"</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"Linke Maustaste, drücken und ziehen, loslassen, um das Ziehen zu beenden"</string>
<string name="text_269f39a07b4a4e01" formatted="false">"Maussteuerung"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"Mausrad, nach oben und unten schieben, um aus dem Tastenbereich zu gleiten"</string>
<string name="text_9aa238fc6121dede" formatted="false">", Paketverlustrate "</string>
<string name="text_0b5d16c21095fe10" formatted="false">", senden "</string>
<string name="text_ba9a792d063a8c86" formatted="false">", Passwort gespeichert"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", empfangen "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", klicken Sie auf Wiederholen"</string>
</resources>
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" · Completado"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" Transferencia fallida"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" dispositivos"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" Artículo · "</string>
<string name="text_2c894057fc526576" formatted="false">" elementos no leídos"</string>
<string name="text_d0ac753c6681e578" formatted="false">"Conexión remota CrossDesk"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL versión 3"</string>
<string name="text_5ae7dc4079223967" formatted="false">"Aviso OpenFEC"</string>
<string name="text_69ad29820a259526" formatted="false">"Licencia y fuente OpenFEC"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"Conexión directa P2P"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRTP habilitado"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"Relé de GIRO"</string>
<string name="text_ebede975edd52aee" formatted="false">"Error en la transmisión {0}"</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"Dispositivos: {0}"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"{0} elementos no leídos"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0}, recepción de {1}, envío de {2}, tasa de pérdida de paquetes {3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● Conectado"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"Tire hacia abajo para actualizar"</string>
<string name="text_37772187ab854191" formatted="false">"Tasa de pérdidas"</string>
<string name="text_a567bdaa11367f26" formatted="false">"Media"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"Da prioridad a mantener la imagen fluida y reducirá la resolución de manera más agresiva cuando el ancho de banda o la presión de codificación sean altos."</string>
<string name="text_aa9e366f68d3d097" formatted="false">"Baja"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"Usando un servidor personalizado"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"Guardar contraseña"</string>
<string name="text_10019cef593819ed" formatted="false">"Guardar archivo recibido"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"Guardar vistas previas de pantalla remotas"</string>
<string name="text_0f197074373e38fd" formatted="false">"¿Guardar vista previa de pantalla remota?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"La conexión de señalización está interrumpida, vuelva a conectarse."</string>
<string name="text_789f22a8b1b34797" formatted="false">"Error al cargar el anuncio, inténtelo de nuevo."</string>
<string name="text_00554b17c432937e" formatted="false">"Detalles del anuncio"</string>
<string name="text_2428420e407fe2e8" formatted="false">"totales "</string>
<string name="text_d977c7568cc5201e" formatted="false">"Total de elementos {0}· Elementos no leídos {1}"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"Acerca de"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"Acerca de CrossDesk"</string>
<string name="text_390a95b65eb33052" formatted="false">"Acerca de, hay una nueva versión."</string>
<string name="text_3fd47edce45b3603" formatted="false">"Cerrar"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"Cerrar barra de control"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"Desactivar la pantalla de privacidad"</string>
<string name="text_7d466b69be42fc43" formatted="false">"Equilibra la claridad y la suavidad, reduciendo solo gradualmente la resolución bajo presión constante."</string>
<string name="text_b92421822c213c2b" formatted="false">"Resolución"</string>
<string name="text_2f9daa828907b93f" formatted="false">"Eliminar"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"Eliminar anuncio"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"Eliminar registro"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"¿Eliminar este anuncio?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"Actualizar anuncio"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"Portapapeles"</string>
<string name="text_4927a53bcc886afb" formatted="false">"Cargando…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"Cargar más"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"Actualización recomendada"</string>
<string name="text_861521c9e107c648" formatted="false">"Falló la inicialización de la conexión nativa"</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"Enviar"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"Enviar "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"Después de enviar, haga clic en \"Pegar\" en el teclado remoto. El texto remoto recibido se puede copiar manualmente a la máquina local."</string>
<string name="text_e7281fbca7af369c" formatted="false">"Enviar archivo"</string>
<string name="text_ce6c02d19022e016" formatted="false">"Enviar texto"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"Cancelar"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"Cancelar conexión"</string>
<string name="text_72814b15a6fa152c" formatted="false">"Cada componente está sujeto a la licencia de código abierto enumerada y los derechos otorgados no están sujetos a los términos generales de uso de esta aplicación. Para ver el código fuente se requiere una conexión a Internet."</string>
<string name="text_e009c148d1890dac" formatted="false">"Total"</string>
<string name="text_81c92013219dd115" formatted="false">"Aceptar y continuar"</string>
<string name="text_409696774c9998ce" formatted="false">"Manténgase conectado en segundo plano, haga clic para regresar al escritorio remoto"</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"No se pudo iniciar el mantenimiento en segundo plano; mantenga la aplicación en primer plano"</string>
<string name="text_d96a21463557171d" formatted="false">"No se pudo iniciar el mantenimiento en segundo plano. Mantenga la aplicación en primer plano y vuelva a conectarse."</string>
<string name="text_952f7300de0a7bd9" formatted="false">"La conexión en segundo plano ha finalizado; vuelva a conectarse."</string>
<string name="text_da904f715e1cff87" formatted="false">"El servicio de conexión en segundo plano se detuvo; vuelva a conectarse."</string>
<string name="text_0812359e18082071" formatted="false">"Las nuevas vistas previas aún se guardarán en conexiones posteriores."</string>
<string name="text_b9086662b1df1f20" formatted="false">"En línea"</string>
<string name="text_39f2f444a042643e" formatted="false">"Complete el nombre del host o la dirección IPv4, sin protocolo ni ruta"</string>
<string name="text_473400815c0f687d" formatted="false">"Sonido"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"Copiar texto remoto"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"Mantenga siempre una resolución más alta y reduzca la velocidad de fotogramas para garantizar la claridad de la imagen cuando el ancho de banda sea insuficiente."</string>
<string name="text_7ff73de85ec16d15" formatted="false">"Cifrado de medios"</string>
<string name="text_22dc6217af77bc37" formatted="false">"Letras y números"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"Error en el almacenamiento seguro o en la inicialización del certificado. Inténtelo de nuevo o borre los datos de la aplicación."</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"Hecho"</string>
<string name="text_678328f6f02881c7" formatted="false">"Puede buscar actualizaciones después de completar la autorización de privacidad"</string>
<string name="text_c4361f9abdba4882" formatted="false">"Registre la identidad local después de completar la autorización de privacidad"</string>
<string name="text_c29b888e357d727d" formatted="false">"Error al guardar la contraseña, esta conexión aún se puede utilizar"</string>
<string name="text_3f31300a23afb390" formatted="false">"Las contraseñas se guardan cifradas en el almacén de claves nativo de Android"</string>
<string name="text_8d3b052c4c67acda" formatted="false">"Identificación remota"</string>
<string name="text_0512084e130984bc" formatted="false">"Identificación de pares "</string>
<string name="text_c5147d65960196b0" formatted="false">"ID de igual {0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"Se eliminarán todas las vistas previas remotas guardadas en esta máquina y se conservarán los registros de conexión y las contraseñas."</string>
<string name="text_60cc99cc0013972e" formatted="false">"Orientación de la pantalla"</string>
<string name="text_9e7294d264afecb7" formatted="false">"Ampliar el menú del control remoto"</string>
<string name="text_9343612ee473edd6" formatted="false">"Conexión remota desconectada de la notificación"</string>
<string name="text_9d32476fe83aef60" formatted="false">"Contraseña guardada"</string>
<string name="text_1cbd391383283924" formatted="false">"La contraseña guardada no se puede leer; vuelva a ingresarla."</string>
<string name="text_6792b9f341380c68" formatted="false">"La contraseña guardada no se puede conservar; vuelva a ingresarla."</string>
<string name="text_74d3f0927d465047" formatted="false">"Ctrl+Alt+Del enviado"</string>
<string name="text_c28d86acbedba3b6" formatted="false">"Mantener vivo en segundo plano está habilitado, cambiar de aplicación puede continuar manteniendo la conexión"</string>
<string name="text_506409ec08cb2a79" formatted="false">"Keepalive en segundo plano habilitado para regresar o desconectarse de las notificaciones de conexión"</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"Concedido"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"Leer"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"Conectado "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"Conectado al servidor"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"Velocidad de fotogramas"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"Fluidez"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"Equilibrado"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"El archivo de licencia de la aplicación falta o está dañado; reinstale la aplicación."</string>
<string name="text_3f2904435942cc0f" formatted="false">"La versión de desarrollo contiene modificaciones que aún no han sido publicadas. El siguiente enlace es el código fuente de la versión básica."</string>
<string name="text_4338afaad52ea445" formatted="false">"Activar guardado"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"Habilitar pantalla de privacidad"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"Componentes de código abierto"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"Descripción de los derechos del software de código abierto"</string>
<string name="text_6e86570183c3cdd0" formatted="false">"Esta es actualmente la última versión."</string>
<string name="text_24a52788cf474bf6" formatted="false">"Actualmente, el extremo remoto no admite la entrada directa de este texto. Utilice una versión del extremo controlado que admita la entrada de texto o envíela a través del portapapeles."</string>
<string name="text_33f25b500697a7d9" formatted="false">"Tras una conexión exitosa, aquí se mostrará el dispositivo remoto más cercano."</string>
<string name="text_70341256d7317e7d" formatted="false">"He leído y acepto la Política de Privacidad"</string>
<string name="text_85c4c8fda0461f35" formatted="false">"Archivo seleccionado (no se puede leer o no hay suficiente espacio)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"No se encontró el ID del dispositivo"</string>
<string name="text_97b7b18df07b9787" formatted="false">"Error al enviar el comando, inténtelo de nuevo"</string>
<string name="text_cb782027ca5986c7" formatted="false">"El comando ha sido enviado, esperando confirmación por computadora..."</string>
<string name="text_3ca40a586103f6b7" formatted="false">"Estado de autorización"</string>
<string name="text_6e684586884ebee6" formatted="false">"Recibir"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"recibir "</string>
<string name="text_d16488b987723161" formatted="false">"Decodificador local"</string>
<string name="text_6b58198955259066" formatted="false">"Retirar"</string>
<string name="text_c52e42ee856825db" formatted="false">"Después de retirarse, se detendrá la conexión remota, se cerrará la vista previa de la pantalla y se borrará la vista previa existente. El registro de conexión y la contraseña se conservarán y se requerirá autorización nuevamente antes de volver a conectarse."</string>
<string name="text_61f4788032ec596b" formatted="false">"Retirar la autorización de red"</string>
<string name="text_e736e419008ac10a" formatted="false">"¿Retirar la autorización de la red?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"Contraer menú de control remoto"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"Ocultar teclado"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"Ocultar ratón"</string>
<string name="text_5440f7424f2865bf" formatted="false">"Datos"</string>
<string name="text_94244f41d0a41378" formatted="false">"Error al guardar el archivo, inténtelo de nuevo"</string>
<string name="text_2553a785fef2a405" formatted="false">"Archivo guardado"</string>
<string name="text_d573b3f9a810ea04" formatted="false">"Se agotó el tiempo de selección de archivos y la sesión finalizó"</string>
<string name="text_985a5c380be49f4f" formatted="false">"El texto supera los 128 KB"</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"Desconectar"</string>
<string name="text_09b1395f50589327" formatted="false">"Volverá a la página de inicio después de desconectarse"</string>
<string name="text_a73c0680db0d2e06" formatted="false">"¿Desconectar la conexión remota?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"Desconectar"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"Nueva versión"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"Nueva versión disponible :v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"Nueva versión disponible: v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"No hay anuncios no leídos"</string>
<string name="text_dca2c418ac967366" formatted="false">"No se puede guardar el estado de lectura localmente. Por favor inténtalo de nuevo."</string>
<string name="text_5c92da76360056cc" formatted="false">"No se puede eliminar este anuncio. Por favor inténtalo de nuevo."</string>
<string name="text_615709dd3129313c" formatted="false">"No se puede iniciar la conexión remota"</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"No se puede leer la licencia de código abierto"</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"Pantalla"</string>
<string name="text_ffe21a671190323e" formatted="false">"Mostrar contraseña"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"Ahora no"</string>
<string name="text_6d9a7758610a2432" formatted="false">"Sin anuncios"</string>
<string name="text_0b290c6600d92f43" formatted="false">"Conexiones recientes"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"Servidor"</string>
<string name="text_dfe408e686175095" formatted="false">"Error en la verificación del certificado Server TLS"</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"Dirección del servidor"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"Error al iniciar sesión en el servidor; verifique la dirección y la red del servidor"</string>
<string name="text_d270868960e68629" formatted="false">"Puerto del servidor"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"Se agotó el tiempo de espera de la conexión del servidor; verifique la dirección y la red del servidor."</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"No habilitado"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"No se encontró ningún navegador para abrir el enlace."</string>
<string name="text_94bc3d40defef3e9" formatted="false">"No autorizado"</string>
<string name="text_4d0680f9efaef147" formatted="false">"No leído"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"Servidor no conectado · Haga clic para intentar nuevamente"</string>
<string name="text_7eea7966c0b746c5" formatted="false">"Esta aplicación utiliza OpenFEC para la corrección de errores directos bajoCeCILL-C1.0, que proporciona garantías limitadas y limita la responsabilidad. Consulte la licencia completa para obtener más detalles."</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"Identificación local "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"ID local {0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"Código fuente e instrucciones de compilación para esta versión"</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"La revisión fuente no está disponible para esta compilación."</string>
<string name="text_f0e274aa130e08a2" formatted="false">"Suelte para actualizar"</string>
<string name="text_47077144715b7074" formatted="false">"Instrucciones de construcción"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"Crear recetas y parches de proyectos"</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"Ver código fuente"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"La verificación falló. Inténtelo de nuevo más tarde."</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"Buscar actualizaciones"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"Cerrando la pantalla de privacidad..."</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"Estableciendo una conexión segura..."</string>
<string name="text_0da575e3c92de73a" formatted="false">"Activando la pantalla de privacidad..."</string>
<string name="text_91834aebecabcbc3" formatted="false">"Buscando actualizaciones..."</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"Borrando..."</string>
<string name="text_0e6336c8fe918730" formatted="false">"Obtención de identificación local..."</string>
<string name="text_faebd20c2801fd0f" formatted="false">"Leyendo el archivo seleccionado..."</string>
<string name="text_694186911ae3e4da" formatted="false">"Conexión"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"Conectándose al servidor de señalización..."</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"Conectándose al servidor..."</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"Reconectándose al servidor..."</string>
<string name="text_4e4fce649fc00238" formatted="false">"No hay selector de archivos disponible para este dispositivo"</string>
<string name="text_bce2377283c2455a" formatted="false">"Borrar"</string>
<string name="text_ee0e76b8507212de" formatted="false">"Borrar vistas previas"</string>
<string name="text_ecc809e44637de18" formatted="false">"¿Borrar vistas previas?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"Código fuente e instrucciones de compilación"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"Versión del código fuente"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"Versión"</string>
<string name="text_16410ee9663fad10" formatted="false">"Declaración de derechos de autor y licencia"</string>
<string name="text_11de77e1e41e680f" formatted="false">"Teclado de computadora"</string>
<string name="text_20f57900234debd7" formatted="false">"Calidad"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"Preferencia de vídeo"</string>
<string name="text_82a5600c470179fa" formatted="false">"Latencia de vídeo"</string>
<string name="text_33b458faaf967e15" formatted="false">"Configuración de vídeo"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"Calidad de vídeo"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"Velocidad de fotogramas de captura de vídeo"</string>
<string name="text_66062971234f422d" formatted="false">"Panel táctil"</string>
<string name="text_c421dc051bea3f11" formatted="false">"Ratón relativo"</string>
<string name="text_de32e20193ad1042" formatted="false">"Entendido"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"Aceptar"</string>
<string name="text_36f33adaf0942634" formatted="false">"Confirmar"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"Confirmar conexión"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"Sin conexión"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"Espacio"</string>
<string name="text_471a579e8d87faac" formatted="false">"El rango de puertos es 1–65535"</string>
<string name="text_c7d0c77862e32b52" formatted="false">"Símbolos y teclas"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"Espere a que la computadora vuelva al estado de pantalla de privacidad; Es posible que las computadoras más antiguas no lo admitan."</string>
<string name="text_5ee880e2524a983a" formatted="false">"Esperando información de la pantalla remota"</string>
<string name="text_674c723a85ee7f57" formatted="false">"Esperando la pantalla remota..."</string>
<string name="text_119f0483da4756c4" formatted="false">"Esperando autorización de privacidad"</string>
<string name="text_7916071c1a8ee458" formatted="false">"Teclado del sistema"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"Toque directo"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"Ratón absoluto"</string>
<string name="text_872428a7210d5e0c" formatted="false">"Red"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"Personalizado"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"Ratón virtual"</string>
<string name="text_62c199f37bf95869" formatted="false">"La pantalla de privacidad remota se desbloqueó usando el acceso directo local. Bloquéelo nuevamente para restaurar la privacidad y deshabilitar el desbloqueo de accesos directos locales."</string>
<string name="text_a569ce729c1d6e19" formatted="false">"La versión controlada es demasiado baja; actualice a la última versión."</string>
<string name="text_d814476826827b78" formatted="false">"Codificador remoto"</string>
<string name="text_488402883e27f6f0" formatted="false">"Texto a enviar al extremo remoto"</string>
<string name="text_c20f7618d330a854" formatted="false">"Vídeo"</string>
<string name="text_310c625425f014d8" formatted="false">"Licencia"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"Licencias y avisos de derechos de autor"</string>
<string name="text_e24134b643a17975" formatted="false">"La licencia y el aviso de derechos de autor están disponibles sin conexión."</string>
<string name="text_5e7628f043ce71d0" formatted="false">"No se pudo guardar la identidad del dispositivo; verifique el almacenamiento seguro"</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"La identidad del dispositivo y las contraseñas de acceso guardadas seleccionadas se cifran mediante Android Keystore. Los registros de conexión solo se guardan localmente y no se incluyen en la copia de seguridad del dispositivo. El contenido del portapapeles se borra cuando finaliza la sesión. Manténgase en sesión con imagen en imagen o notificaciones conectadas después de salir de la aplicación y desconéctese dentro de la aplicación o las notificaciones."</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"Ajustes"</string>
<string name="text_4f8771952222a011" formatted="false">"Configuración, hay una nueva versión."</string>
<string name="text_f4f712d5be91721a" formatted="false">"Contraseña de acceso"</string>
<string name="text_dd6c465847af1b60" formatted="false">"La contraseña de acceso es incorrecta, por favor vuelva a ingresarla."</string>
<string name="text_defba1d2989bfab4" formatted="false">"Error en la verificación del certificado · Haga clic para intentar nuevamente"</string>
<string name="text_44f3d4d52347095a" formatted="false">"Primero complete la autorización en línea"</string>
<string name="text_2a07ca002bc5093d" formatted="false">"Habilite el servicio del sistema CrossDesk en el extremo remoto y vuelva a intentarlo."</string>
<string name="text_990fd23c62de5ccf" formatted="false">"Se agotó el tiempo de espera de la solicitud y no se ha confirmado el estado de la pantalla de privacidad de la computadora."</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"Espere a que se envíe el archivo actual."</string>
<string name="text_9305d9d72b06110d" formatted="false">"Ingrese un puerto en el rango 1–65535"</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"Ingrese una ID de dispositivo de 9 dígitos"</string>
<string name="text_285a0714894d2899" formatted="false">"Por favor ingrese una contraseña de acceso válida"</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"No se pudieron aplicar las configuraciones. Intentar otra vez."</string>
<string name="text_a762e7e650e285fa" formatted="false">"Licencia de software"</string>
<string name="text_572cf45ba43634b3" formatted="false">"Volver"</string>
<string name="text_7565f94031582c7c" formatted="false">"Volver a la barra de control"</string>
<string name="text_55113b6925c362fd" formatted="false">"Aún no hay registro de conexión"</string>
<string name="text_3aa527b2106cc987" formatted="false">"Este anuncio ha sido actualizado o eliminado, regrese a la lista de anuncios para verlo."</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"La tarea remota está cerrada"</string>
<string name="text_c255841f68096d3b" formatted="false">"Escritorio remoto"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"Área táctil del escritorio remoto"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"Escritorio remoto: toque y haga clic, arrastre para mover, presione y mantenga presionado el botón derecho, pellizque para hacer zoom, acerque y arrastre la pantalla con dos dedos, deslice hacia arriba y hacia abajo con dos dedos para desplazarse a la escala original"</string>
<string name="text_3535087994d050de" formatted="false">"Computadora remota"</string>
<string name="text_ed2e03072ce131de" formatted="false">"La ventana remota se cerró o el mantenimiento en segundo plano no se pudo iniciar y la sesión finalizó."</string>
<string name="text_678b783fb578172b" formatted="false">"Conexión remota"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"Contraseña remota incorrecta"</string>
<string name="text_a2ce499295e358ae" formatted="false">"El extremo remoto ha cerrado la conexión"</string>
<string name="text_176e117df3ace734" formatted="false">"El extremo remoto no está disponible actualmente"</string>
<string name="text_d2c0694243f93249" formatted="false">"La contraseña de acceso remoto ha caducado, vuelva a conectarse."</string>
<string name="text_a5574109f0208e89" formatted="false">"Conectar"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"Conexión "</string>
<string name="text_89c4766afc85125f" formatted="false">"Error de conexión"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"Error de conexión · Haga clic para intentarlo de nuevo"</string>
<string name="text_2ab7d89557e779ab" formatted="false">"Conexión perdida"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"Conexión finalizada"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"Retardo de conexión (RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"Disponible cuando se conecta a un dispositivo remoto compatible."</string>
<string name="text_2123a9ed718f2c40" formatted="false">"Bloquear la pantalla horizontal después de establecer la conexión"</string>
<string name="text_f741e0536210eafa" formatted="false">"Aviso de conexión"</string>
<string name="text_485a26050ce57431" formatted="false">"Método de conexión"</string>
<string name="text_5a048351a77fe88d" formatted="false">"Conectarse al servidor"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"Conéctese al servidor para obtener los últimos anuncios."</string>
<string name="text_32cd27b5a72515f3" formatted="false">"Se agotó el tiempo de conexión; verifique el ID del dispositivo, el servidor y el estado en línea remoto"</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"Conectarse al escritorio remoto"</string>
<string name="text_d5407b4f3c666681" formatted="false">"Retroceso"</string>
<string name="text_e02926784fcedb91" formatted="false">"Anuncios"</string>
<string name="text_465a092680d132b3" formatted="false">"Algunas vistas previas no se pudieron borrar, inténtalo de nuevo."</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"Inténtalo de nuevo"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"Reintentar guardar el estado de lectura"</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"Bloquear"</string>
<string name="text_864200bbb241e067" formatted="false">"Teclado"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"Leer y autorizar"</string>
<string name="text_86651d17a401c55b" formatted="false">"Privacidad"</string>
<string name="text_26c6e011e343708c" formatted="false">"Privacidad y autorización"</string>
<string name="text_6cad42fd3da85585" formatted="false">"Pantalla de privacidad"</string>
<string name="text_e157fafb76aad954" formatted="false">"Privacidad desactivada"</string>
<string name="text_bbef0eac0c04f214" formatted="false">"Privacidad activada"</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"Pantalla de privacidad desbloqueada"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"Anormalidad en la pantalla de privacidad"</string>
<string name="text_598a2747dbd21dd5" formatted="false">"La pantalla de privacidad no está disponible temporalmente"</string>
<string name="text_4d59be3c75189032" formatted="false">"El estado de la pantalla de privacidad ha caducado, esperando confirmación por computadora"</string>
<string name="text_1674532c70ed1db2" formatted="false">"Estado de la pantalla de privacidad por confirmar"</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"Política de privacidad"</string>
<string name="text_554e957e8e3c00af" formatted="false">"La política de privacidad no se puede leer temporalmente. Rechace conectarse a Internet y reinstale la aplicación."</string>
<string name="text_7db2e189ecb01065" formatted="false">"Silencio"</string>
<string name="text_296c632ec857a0ba" formatted="false">"Audio"</string>
<string name="text_0cadb49096a382e0" formatted="false">"Sitio web del proyecto"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"Las vistas previas pueden contener información personal o laboral de la computadora remota. Después de encenderlo, cada conexión guardará automáticamente una imagen en la máquina y la mostrará en las conexiones recientes. Puedes cerrar y borrar en cualquier momento."</string>
<string name="text_b1c27820fec23edb" formatted="false">"Alta"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"Predeterminado"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"Desactivado de forma predeterminada. Cuando esté encendido, la pantalla entrará y permanecerá en orientación horizontal cuando el teléfono esté conectado; cuando esté apagado, seguirá la dirección del dispositivo. Las tabletas y las ventanas múltiples no se ven afectadas."</string>
<string name="text_1184a97edd8e1646" formatted="false">"Botón derecho del mouse, presione y arrastre, suelte para finalizar el arrastre"</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"Botón izquierdo del mouse, presione y arrastre, suelte para finalizar el arrastre"</string>
<string name="text_269f39a07b4a4e01" formatted="false">"Control del ratón"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"Rueda del mouse, deslícela hacia arriba y hacia abajo para salir del rango de teclas"</string>
<string name="text_9aa238fc6121dede" formatted="false">", tasa de pérdida de paquetes "</string>
<string name="text_0b5d16c21095fe10" formatted="false">", enviar "</string>
<string name="text_ba9a792d063a8c86" formatted="false">", contraseña guardada"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", recibir "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", haga clic en Reintentar"</string>
</resources>
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" · Terminé"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" Échec du transfert"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" appareils"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" Article · "</string>
<string name="text_2c894057fc526576" formatted="false">" éléments non lus"</string>
<string name="text_d0ac753c6681e578" formatted="false">"Connexion à distance CrossDesk"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL version 3"</string>
<string name="text_5ae7dc4079223967" formatted="false">"Avis OpenFEC"</string>
<string name="text_69ad29820a259526" formatted="false">"Licence et source OpenFEC"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"Connexion directe P2P"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRTP activé"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"Relais TOUR"</string>
<string name="text_ebede975edd52aee" formatted="false">"Échec de la transmission {0}"</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"Appareils : {0}"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"Éléments non lus {0}"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0}, réception de {1}, envoi de {2}, taux de perte de paquets {3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● Connecté"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"Déroulez pour actualiser"</string>
<string name="text_37772187ab854191" formatted="false">"Taux de perte"</string>
<string name="text_a567bdaa11367f26" formatted="false">"Moyenne"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"Donne la priorité au maintien de la fluidité de l\'image et réduira la résolution de manière plus agressive lorsque la bande passante ou la pression d\'encodage est élevée."</string>
<string name="text_aa9e366f68d3d097" formatted="false">"Faible"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"Utilisation d\'un serveur personnalisé"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"Enregistrer le mot de passe"</string>
<string name="text_10019cef593819ed" formatted="false">"Enregistrer le fichier reçu"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"Enregistrer les aperçus de l\'écran distant"</string>
<string name="text_0f197074373e38fd" formatted="false">"Enregistrer l\'aperçu de l\'écran distant ?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"La connexion de signalisation est interrompue, veuillez vous reconnecter."</string>
<string name="text_789f22a8b1b34797" formatted="false">"Le chargement de l\'annonce a échoué, veuillez réessayer."</string>
<string name="text_00554b17c432937e" formatted="false">"Détails de l\'annonce"</string>
<string name="text_2428420e407fe2e8" formatted="false">"Total "</string>
<string name="text_d977c7568cc5201e" formatted="false">"Total des éléments {0}· Éléments non lus {1}"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"À propos"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"À propos de CrossDesk"</string>
<string name="text_390a95b65eb33052" formatted="false">"À propos, il existe une nouvelle version"</string>
<string name="text_3fd47edce45b3603" formatted="false">"Fermer"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"Fermer la barre de contrôle"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"Désactiver l\'écran de confidentialité"</string>
<string name="text_7d466b69be42fc43" formatted="false">"Équilibre la clarté et la douceur, en réduisant seulement progressivement la résolution sous une pression constante."</string>
<string name="text_b92421822c213c2b" formatted="false">"Résolution"</string>
<string name="text_2f9daa828907b93f" formatted="false">"Supprimer"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"Supprimer l\'annonce"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"Supprimer l\'enregistrement"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"Supprimer cette annonce ?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"Actualiser l\'annonce"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"Presse-papiers"</string>
<string name="text_4927a53bcc886afb" formatted="false">"Chargement…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"Charger plus"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"Mise à jour conseillée"</string>
<string name="text_861521c9e107c648" formatted="false">"L\'initialisation de la connexion native a échoué"</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"Envoi"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"Envoyer "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"Après l\'envoi, cliquez sur \"Coller\" sur le clavier distant. Le texte distant reçu peut être copié manuellement sur la machine locale."</string>
<string name="text_e7281fbca7af369c" formatted="false">"Envoyer le fichier"</string>
<string name="text_ce6c02d19022e016" formatted="false">"Envoyer un SMS"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"Annuler"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"Annuler la connexion"</string>
<string name="text_72814b15a6fa152c" formatted="false">"Chaque composant est soumis à la licence open source répertoriée, et les droits accordés ne sont pas soumis aux conditions générales d\'utilisation de cette application. La visualisation du code source nécessite une connexion Internet."</string>
<string name="text_e009c148d1890dac" formatted="false">"Total"</string>
<string name="text_81c92013219dd115" formatted="false">"Accepter et continuer"</string>
<string name="text_409696774c9998ce" formatted="false">"Restez connecté en arrière-plan, cliquez pour revenir au bureau distant"</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"Échec du démarrage de Keepalive en arrière-plan, veuillez conserver l\'application au premier plan"</string>
<string name="text_d96a21463557171d" formatted="false">"L\'application Keepalive en arrière-plan n\'a pas pu démarrer. Veuillez conserver l\'application au premier plan et vous reconnecter."</string>
<string name="text_952f7300de0a7bd9" formatted="false">"La connexion en arrière-plan est terminée, veuillez vous reconnecter."</string>
<string name="text_da904f715e1cff87" formatted="false">"Le service de connexion en arrière-plan s\'est arrêté, veuillez vous reconnecter."</string>
<string name="text_0812359e18082071" formatted="false">"Les nouveaux aperçus seront toujours enregistrés lors des connexions ultérieures."</string>
<string name="text_b9086662b1df1f20" formatted="false">"En ligne"</string>
<string name="text_39f2f444a042643e" formatted="false">"Remplissez le nom d\'hôte ou l\'adresse IPv4, sans protocole ni chemin"</string>
<string name="text_473400815c0f687d" formatted="false">"Son"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"Copier le texte distant"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"Maintenez toujours une résolution plus élevée et réduisez la fréquence d\'images pour garantir la clarté de l\'image lorsque la bande passante est insuffisante."</string>
<string name="text_7ff73de85ec16d15" formatted="false">"Cryptage multimédia"</string>
<string name="text_22dc6217af77bc37" formatted="false">"Lettres et chiffres"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"Échec du stockage sécurisé ou de l\'initialisation du certificat. Veuillez réessayer ou effacer les données de l\'application."</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"Terminé"</string>
<string name="text_678328f6f02881c7" formatted="false">"Vous pouvez vérifier les mises à jour après avoir terminé l\'autorisation de confidentialité"</string>
<string name="text_c4361f9abdba4882" formatted="false">"Enregistrez l\'identité locale après avoir terminé l\'autorisation de confidentialité"</string>
<string name="text_c29b888e357d727d" formatted="false">"L\'enregistrement du mot de passe a échoué, cette connexion peut toujours être utilisée"</string>
<string name="text_3f31300a23afb390" formatted="false">"Les mots de passe sont enregistrés cryptés dans le Keystore Android natif"</string>
<string name="text_8d3b052c4c67acda" formatted="false">"ID à distance"</string>
<string name="text_0512084e130984bc" formatted="false">"Identification des pairs "</string>
<string name="text_c5147d65960196b0" formatted="false">"ID homologue {0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"Tous les aperçus à distance enregistrés sur cette machine seront supprimés et les enregistrements de connexion et les mots de passe seront conservés."</string>
<string name="text_60cc99cc0013972e" formatted="false">"Orientation de l\'écran"</string>
<string name="text_9e7294d264afecb7" formatted="false">"Développer le menu de la télécommande"</string>
<string name="text_9343612ee473edd6" formatted="false">"Connexion à distance déconnectée de la notification"</string>
<string name="text_9d32476fe83aef60" formatted="false">"Mot de passe enregistré"</string>
<string name="text_1cbd391383283924" formatted="false">"Le mot de passe enregistré ne peut pas être lu, veuillez le saisir à nouveau."</string>
<string name="text_6792b9f341380c68" formatted="false">"Le mot de passe enregistré ne peut pas être conservé, veuillez le saisir à nouveau."</string>
<string name="text_74d3f0927d465047" formatted="false">"Ctrl+Alt+Del envoyé"</string>
<string name="text_c28d86acbedba3b6" formatted="false">"Le maintien de l\'activité en arrière-plan est activé, les applications de commutation peuvent continuer à maintenir la connexion."</string>
<string name="text_506409ec08cb2a79" formatted="false">"Keepalive en arrière-plan activé pour revenir ou se déconnecter des notifications de connexion"</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"Accordé"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"Lire"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"Connecté "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"Connecté au serveur"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"Fréquence d\'images"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"Fluidité"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"Équilibré"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"Le fichier de licence dans l\'application est manquant ou endommagé, veuillez réinstaller l\'application."</string>
<string name="text_3f2904435942cc0f" formatted="false">"La version de développement contient des modifications qui n\'ont pas encore été publiées. Le lien suivant est le code source de la version de base."</string>
<string name="text_4338afaad52ea445" formatted="false">"Activer l’enregistrement"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"Activer l\'écran de confidentialité"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"Composants open source"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"Description des droits sur les logiciels open source"</string>
<string name="text_6e86570183c3cdd0" formatted="false">"Il s\'agit actuellement de la dernière version"</string>
<string name="text_24a52788cf474bf6" formatted="false">"Actuellement, l\'extrémité distante ne prend pas en charge la saisie directe de ce texte. Veuillez utiliser une version de l\'extrémité contrôlée qui prend en charge la saisie de texte ou envoyez-la via le presse-papiers."</string>
<string name="text_33f25b500697a7d9" formatted="false">"Une fois la connexion réussie, l\'appareil distant le plus proche sera affiché ici."</string>
<string name="text_70341256d7317e7d" formatted="false">"J\'ai lu et accepté la politique de confidentialité"</string>
<string name="text_85c4c8fda0461f35" formatted="false">"Fichier sélectionné (impossible en lecture ou espace insuffisant)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"L\'ID de l\'appareil n\'a pas été trouvé"</string>
<string name="text_97b7b18df07b9787" formatted="false">"L\'envoi de la commande a échoué, veuillez réessayer"</string>
<string name="text_cb782027ca5986c7" formatted="false">"La commande a été envoyée, en attente de confirmation informatique..."</string>
<string name="text_3ca40a586103f6b7" formatted="false">"Statut d\'autorisation"</string>
<string name="text_6e684586884ebee6" formatted="false">"Réception"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"recevoir "</string>
<string name="text_d16488b987723161" formatted="false">"Décodeur local"</string>
<string name="text_6b58198955259066" formatted="false">"Retirer"</string>
<string name="text_c52e42ee856825db" formatted="false">"Après le retrait, la connexion à distance sera arrêtée, l\'aperçu de l\'écran sera fermé et l\'aperçu existant sera effacé. L\'enregistrement de connexion et le mot de passe seront conservés et une autorisation sera à nouveau requise avant de se reconnecter."</string>
<string name="text_61f4788032ec596b" formatted="false">"Retirer l\'autorisation réseau"</string>
<string name="text_e736e419008ac10a" formatted="false">"Retirer l\'autorisation réseau ?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"Réduire le menu de la télécommande"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"Masquer le clavier"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"Masquer la souris"</string>
<string name="text_5440f7424f2865bf" formatted="false">"Données"</string>
<string name="text_94244f41d0a41378" formatted="false">"L\'enregistrement du fichier a échoué, veuillez réessayer"</string>
<string name="text_2553a785fef2a405" formatted="false">"Fichier enregistré"</string>
<string name="text_d573b3f9a810ea04" formatted="false">"La sélection de fichier a expiré et la session s\'est terminée"</string>
<string name="text_985a5c380be49f4f" formatted="false">"Le texte dépasse 128 Ko"</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"Déconnecter"</string>
<string name="text_09b1395f50589327" formatted="false">"Reviendra à la page d\'accueil après la déconnexion"</string>
<string name="text_a73c0680db0d2e06" formatted="false">"Déconnecter la connexion à distance ?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"Déconnecter"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"Nouvelle version"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"Nouvelle version disponible : v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"Nouvelle version disponible : v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"Aucune annonce non lue"</string>
<string name="text_dca2c418ac967366" formatted="false">"Impossible d\'enregistrer l\'état de lecture localement. Veuillez réessayer."</string>
<string name="text_5c92da76360056cc" formatted="false">"Impossible de supprimer cette annonce. Veuillez réessayer."</string>
<string name="text_615709dd3129313c" formatted="false">"Impossible d\'établir une connexion à distance"</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"Impossible de lire la licence open source"</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"Écran"</string>
<string name="text_ffe21a671190323e" formatted="false">"Afficher le mot de passe"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"Pas maintenant"</string>
<string name="text_6d9a7758610a2432" formatted="false">"Aucune annonce"</string>
<string name="text_0b290c6600d92f43" formatted="false">"Connexions récentes"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"Serveur"</string>
<string name="text_dfe408e686175095" formatted="false">"La vérification du certificat Server TLS a échoué"</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"Adresse du serveur"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"Échec de la connexion au serveur, veuillez vérifier l\'adresse du serveur et le réseau"</string>
<string name="text_d270868960e68629" formatted="false">"Port du serveur"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"La connexion au serveur a expiré, veuillez vérifier l\'adresse du serveur et le réseau"</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"Non activé"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"Aucun navigateur trouvé pour ouvrir le lien"</string>
<string name="text_94bc3d40defef3e9" formatted="false">"Non autorisé"</string>
<string name="text_4d0680f9efaef147" formatted="false">"Non lu"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"Serveur non connecté · Cliquez pour réessayer"</string>
<string name="text_7eea7966c0b746c5" formatted="false">"Cette application utilise OpenFEC pour la correction d\'erreur directe sousCeCILL-C1.0, qui offre des garanties limitées et limite la responsabilité. Consultez la licence complète pour plus de détails."</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"ID local "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"ID local {0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"Code source et instructions de construction pour cette version"</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"La révision source n\'est pas disponible pour cette version."</string>
<string name="text_f0e274aa130e08a2" formatted="false">"Relâchez pour actualiser"</string>
<string name="text_47077144715b7074" formatted="false">"Instructions de construction"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"Créer des recettes et des correctifs de projet"</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"Afficher le code source"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"La vérification a échoué. Veuillez réessayer plus tard."</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"Rechercher des mises à jour"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"Fermeture de l\'écran de confidentialité…"</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"Établir une connexion sécurisée…"</string>
<string name="text_0da575e3c92de73a" formatted="false">"Activation de l\'écran de confidentialité..."</string>
<string name="text_91834aebecabcbc3" formatted="false">"Vérification des mises à jour…"</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"Effacement..."</string>
<string name="text_0e6336c8fe918730" formatted="false">"Obtention d\'une pièce d\'identité locale..."</string>
<string name="text_faebd20c2801fd0f" formatted="false">"Lecture du fichier sélectionné..."</string>
<string name="text_694186911ae3e4da" formatted="false">"Connexion"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"Connexion au serveur de signalisation..."</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"Connexion au serveur..."</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"Reconnexion au serveur..."</string>
<string name="text_4e4fce649fc00238" formatted="false">"Aucun sélecteur de fichiers disponible pour cet appareil"</string>
<string name="text_bce2377283c2455a" formatted="false">"Effacer"</string>
<string name="text_ee0e76b8507212de" formatted="false">"Supprimer les aperçus"</string>
<string name="text_ecc809e44637de18" formatted="false">"Supprimer les aperçus ?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"Code source et instructions de construction"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"Version du code source"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"Version"</string>
<string name="text_16410ee9663fad10" formatted="false">"Déclaration de droits d\'auteur et de licence"</string>
<string name="text_11de77e1e41e680f" formatted="false">"Clavier d\'ordinateur"</string>
<string name="text_20f57900234debd7" formatted="false">"Qualité"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"Préférence vidéo"</string>
<string name="text_82a5600c470179fa" formatted="false">"Latence vidéo"</string>
<string name="text_33b458faaf967e15" formatted="false">"Paramètres vidéo"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"Qualité vidéo"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"Fréquence d\'images de capture vidéo"</string>
<string name="text_66062971234f422d" formatted="false">"Pavé tactile"</string>
<string name="text_c421dc051bea3f11" formatted="false">"Souris relative"</string>
<string name="text_de32e20193ad1042" formatted="false">"Compris"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"D\'accord"</string>
<string name="text_36f33adaf0942634" formatted="false">"Confirmer"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"Confirmer la connexion"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"Hors ligne"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"Espace"</string>
<string name="text_471a579e8d87faac" formatted="false">"La plage de ports est comprise entre 1 et 65 535."</string>
<string name="text_c7d0c77862e32b52" formatted="false">"Symboles et touches"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"Attendez que l\'ordinateur revienne à l\'état de l\'écran de confidentialité ; les ordinateurs plus anciens peuvent ne pas le prendre en charge."</string>
<string name="text_5ee880e2524a983a" formatted="false">"En attente d\'informations sur l\'écran distant"</string>
<string name="text_674c723a85ee7f57" formatted="false">"En attente de l\'écran distant..."</string>
<string name="text_119f0483da4756c4" formatted="false">"En attente d\'une autorisation de confidentialité"</string>
<string name="text_7916071c1a8ee458" formatted="false">"Clavier système"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"Toucher direct"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"Souris absolue"</string>
<string name="text_872428a7210d5e0c" formatted="false">"Réseau"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"Personnalisé"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"Souris virtuelle"</string>
<string name="text_62c199f37bf95869" formatted="false">"L\'écran de confidentialité à distance a été déverrouillé à l\'aide du raccourci local. Verrouillez-le à nouveau pour restaurer la confidentialité et désactiver le déverrouillage des raccourcis locaux."</string>
<string name="text_a569ce729c1d6e19" formatted="false">"La version contrôlée est trop basse, veuillez mettre à niveau vers la dernière version."</string>
<string name="text_d814476826827b78" formatted="false">"Encodeur déporté"</string>
<string name="text_488402883e27f6f0" formatted="false">"Texte à envoyer à l\'extrémité distante"</string>
<string name="text_c20f7618d330a854" formatted="false">"Vidéo"</string>
<string name="text_310c625425f014d8" formatted="false">"Licence"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"Licences et mentions de droits d\'auteur"</string>
<string name="text_e24134b643a17975" formatted="false">"La licence et la notice de copyright sont disponibles hors ligne."</string>
<string name="text_5e7628f043ce71d0" formatted="false">"L\'identité de l\'appareil n\'a pas pu être enregistrée. Veuillez vérifier le stockage sécurisé."</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"L\'identité de l\'appareil et les mots de passe d\'accès enregistrés sélectionnés sont cryptés à l\'aide d\'Android Keystore. Les enregistrements de connexion sont uniquement enregistrés localement et ne sont pas inclus dans la sauvegarde de l\'appareil. Le contenu du presse-papiers est effacé à la fin de la session. Restez en session avec des notifications d\'image dans l\'image ou connectées après avoir quitté l\'application, et déconnectez-vous de l\'application ou des notifications."</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"Réglages"</string>
<string name="text_4f8771952222a011" formatted="false">"Paramètres, il existe une nouvelle version"</string>
<string name="text_f4f712d5be91721a" formatted="false">"Mot de passe d\'accès"</string>
<string name="text_dd6c465847af1b60" formatted="false">"Le mot de passe d\'accès est erroné, veuillez le saisir à nouveau."</string>
<string name="text_defba1d2989bfab4" formatted="false">"Échec de la vérification du certificat · Cliquez pour réessayer"</string>
<string name="text_44f3d4d52347095a" formatted="false">"Veuillez d\'abord remplir l\'autorisation en ligne"</string>
<string name="text_2a07ca002bc5093d" formatted="false">"Veuillez activer le service système CrossDesk sur l\'extrémité distante et réessayer."</string>
<string name="text_990fd23c62de5ccf" formatted="false">"Le délai de demande a expiré et l\'état de l\'écran de confidentialité de l\'ordinateur n\'a pas été confirmé."</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"Veuillez attendre que le fichier actuel soit envoyé."</string>
<string name="text_9305d9d72b06110d" formatted="false">"Veuillez saisir un port compris entre 1 et 65535."</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"Saisissez un identifiant d\'appareil à 9 chiffres"</string>
<string name="text_285a0714894d2899" formatted="false">"Veuillez saisir un mot de passe d\'accès valide"</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"Impossible d\'appliquer les paramètres. Essayer à nouveau."</string>
<string name="text_a762e7e650e285fa" formatted="false">"Licence du logiciel"</string>
<string name="text_572cf45ba43634b3" formatted="false">"Retour"</string>
<string name="text_7565f94031582c7c" formatted="false">"Retour à la barre de contrôle"</string>
<string name="text_55113b6925c362fd" formatted="false">"Aucun enregistrement de connexion pour l\'instant"</string>
<string name="text_3aa527b2106cc987" formatted="false">"Cette annonce a été mise à jour ou supprimée, veuillez revenir à la liste des annonces pour la consulter."</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"La tâche distante est fermée"</string>
<string name="text_c255841f68096d3b" formatted="false">"Bureau à distance"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"Zone tactile du bureau à distance"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"Bureau à distance : touchez et cliquez, faites glisser pour déplacer, appuyez et maintenez le bouton droit enfoncé, pincez pour zoomer, zoomez et faites glisser l\'écran avec deux doigts, faites glisser de haut en bas avec deux doigts pour faire défiler à l\'échelle d\'origine."</string>
<string name="text_3535087994d050de" formatted="false">"Ordinateur distant"</string>
<string name="text_ed2e03072ce131de" formatted="false">"La fenêtre distante a été fermée ou le keepalive en arrière-plan n\'a pas pu démarrer et la session est terminée."</string>
<string name="text_678b783fb578172b" formatted="false">"Connexion à distance"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"Mot de passe distant incorrect"</string>
<string name="text_a2ce499295e358ae" formatted="false">"L\'extrémité distante a fermé la connexion"</string>
<string name="text_176e117df3ace734" formatted="false">"L\'extrémité distante est actuellement indisponible"</string>
<string name="text_d2c0694243f93249" formatted="false">"Le mot de passe d\'accès à distance a expiré, veuillez vous reconnecter."</string>
<string name="text_a5574109f0208e89" formatted="false">"Connecter"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"Connexion "</string>
<string name="text_89c4766afc85125f" formatted="false">"Échec de la connexion"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"Échec de la connexion · Cliquez pour réessayer"</string>
<string name="text_2ab7d89557e779ab" formatted="false">"Connexion perdue"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"Connexion terminée"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"Délai de connexion (RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"Disponible lors de la connexion à un appareil distant pris en charge."</string>
<string name="text_2123a9ed718f2c40" formatted="false">"Verrouiller l\'écran paysage une fois la connexion établie"</string>
<string name="text_f741e0536210eafa" formatted="false">"Invite de connexion"</string>
<string name="text_485a26050ce57431" formatted="false">"Méthode de connexion"</string>
<string name="text_5a048351a77fe88d" formatted="false">"Connectez-vous au serveur"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"Connectez-vous au serveur pour recevoir les dernières annonces."</string>
<string name="text_32cd27b5a72515f3" formatted="false">"La connexion a expiré. Veuillez vérifier l\'ID de l\'appareil, le serveur et l\'état en ligne à distance."</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"Connectez-vous au bureau à distance"</string>
<string name="text_d5407b4f3c666681" formatted="false">"Retour arrière"</string>
<string name="text_e02926784fcedb91" formatted="false">"Annonces"</string>
<string name="text_465a092680d132b3" formatted="false">"Certains aperçus n\'ont pas pu être effacés, veuillez réessayer."</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"Réessayez"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"Réessayez d\'enregistrer l\'état de lecture"</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"Verrouillage"</string>
<string name="text_864200bbb241e067" formatted="false">"Clavier"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"Lire et autoriser"</string>
<string name="text_86651d17a401c55b" formatted="false">"Confidentialité"</string>
<string name="text_26c6e011e343708c" formatted="false">"Confidentialité et autorisation"</string>
<string name="text_6cad42fd3da85585" formatted="false">"Écran de confidentialité"</string>
<string name="text_e157fafb76aad954" formatted="false">"Confidentialité inactive"</string>
<string name="text_bbef0eac0c04f214" formatted="false">"Confidentialité active"</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"Écran de confidentialité déverrouillé"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"Anomalie de l\'écran de confidentialité"</string>
<string name="text_598a2747dbd21dd5" formatted="false">"L\'écran de confidentialité est temporairement indisponible"</string>
<string name="text_4d59be3c75189032" formatted="false">"L\'état de l\'écran de confidentialité a expiré, en attente de confirmation par l\'ordinateur"</string>
<string name="text_1674532c70ed1db2" formatted="false">"Statut de l\'écran de confidentialité à confirmer"</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"Politique de confidentialité"</string>
<string name="text_554e957e8e3c00af" formatted="false">"La politique de confidentialité ne peut pas être lue temporairement. Veuillez refuser de vous connecter à Internet et réinstaller l\'application."</string>
<string name="text_7db2e189ecb01065" formatted="false">"Muet"</string>
<string name="text_296c632ec857a0ba" formatted="false">"Audio"</string>
<string name="text_0cadb49096a382e0" formatted="false">"Site Web du projet"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"Les aperçus peuvent contenir des informations personnelles ou professionnelles provenant de l\'ordinateur distant. Après l\'avoir allumé, chaque connexion enregistrera automatiquement une image sur la machine et l\'affichera dans les connexions récentes. Vous pouvez fermer et effacer à tout moment."</string>
<string name="text_b1c27820fec23edb" formatted="false">"Élevée"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"Par défaut"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"Désactivé par défaut. Lorsqu\'il est allumé, l\'écran entre et reste en orientation paysage lorsque le téléphone est connecté ; lorsqu\'il est éteint, il suivra la direction de l\'appareil. Les tablettes et multi-fenêtres ne sont pas concernées."</string>
<string name="text_1184a97edd8e1646" formatted="false">"Bouton droit de la souris, appuyez et faites glisser, relâchez pour terminer le glisser"</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"Bouton gauche de la souris, appuyez et faites glisser, relâchez pour terminer le glisser"</string>
<string name="text_269f39a07b4a4e01" formatted="false">"Contrôle de la souris"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"Molette de la souris, faites-la glisser de haut en bas pour sortir de la plage de touches"</string>
<string name="text_9aa238fc6121dede" formatted="false">", taux de perte de paquets "</string>
<string name="text_0b5d16c21095fe10" formatted="false">", envoyer "</string>
<string name="text_ba9a792d063a8c86" formatted="false">", mot de passe enregistré"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", recevoir "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", cliquez sur Réessayer"</string>
</resources>
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" ・完了"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" 転送に失敗しました"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" 台のデバイス"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" 記事 ・ "</string>
<string name="text_2c894057fc526576" formatted="false">" 未読項目"</string>
<string name="text_d0ac753c6681e578" formatted="false">"CrossDeskリモート接続"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL バージョン 3"</string>
<string name="text_5ae7dc4079223967" formatted="false">"OpenFEC通知"</string>
<string name="text_69ad29820a259526" formatted="false">"OpenFECライセンスとソース"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"P2P直接接続"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRT有効"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"ターンリレー"</string>
<string name="text_ebede975edd52aee" formatted="false">"{0}送信失敗"</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"デバイス数: {0}"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"{0}の未読アイテム"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0}、受信{1}、送信{2}、パケットロス率{3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● 接続されました"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"プルダウンして更新します"</string>
<string name="text_37772187ab854191" formatted="false">"損失率"</string>
<string name="text_a567bdaa11367f26" formatted="false">"中"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"画像を滑らかに保つことを優先し、帯域幅やエンコード圧力が高い場合には、より積極的に解像度を下げます。"</string>
<string name="text_aa9e366f68d3d097" formatted="false">"低"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"カスタムサーバーの使用"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"パスワードの保存"</string>
<string name="text_10019cef593819ed" formatted="false">"受信したファイルを保存する"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"リモート画面のプレビューを保存する"</string>
<string name="text_0f197074373e38fd" formatted="false">"リモート画面のプレビューを保存しますか?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"信号接続が中断されました。再接続してください。"</string>
<string name="text_789f22a8b1b34797" formatted="false">"お知らせを読み込めませんでした。再試行してください。"</string>
<string name="text_00554b17c432937e" formatted="false">"発表内容"</string>
<string name="text_2428420e407fe2e8" formatted="false">"合計 "</string>
<string name="text_d977c7568cc5201e" formatted="false">"合計{0}個のアイテム ·{1}個の未読アイテム"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"概要"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"CrossDesk について"</string>
<string name="text_390a95b65eb33052" formatted="false">"について、新しいバージョンがあります"</string>
<string name="text_3fd47edce45b3603" formatted="false">"閉じる"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"コントロールバーを閉じる"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"プライバシー画面を無効にする"</string>
<string name="text_7d466b69be42fc43" formatted="false">"明瞭さと滑らかさのバランスを保ち、一定の圧力下で解像度を徐々に低下させるだけです。"</string>
<string name="text_b92421822c213c2b" formatted="false">"解像度"</string>
<string name="text_2f9daa828907b93f" formatted="false">"削除"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"お知らせの削除"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"レコードの削除"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"このお知らせを削除しますか?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"リフレッシュのお知らせ"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"クリップボード"</string>
<string name="text_4927a53bcc886afb" formatted="false">"読み込み中…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"さらに読み込む"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"更新のご案内"</string>
<string name="text_861521c9e107c648" formatted="false">"ネイティブ接続の初期化に失敗しました"</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"送信"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"送信 "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"送信後、リモートキーボードの「貼り付け」をクリックしてください。受信したリモート テキストはローカル マシンに手動でコピーできます。"</string>
<string name="text_e7281fbca7af369c" formatted="false">"ファイル送信"</string>
<string name="text_ce6c02d19022e016" formatted="false">"テキストを送信する"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"キャンセル"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"接続をキャンセルする"</string>
<string name="text_72814b15a6fa152c" formatted="false">"各コンポーネントは、リストされているオープン ソース ライセンスの対象となり、付与された権利は、このアプリケーションの一般的な使用条件の対象ではありません。ソース コードを表示するには、インターネット接続が必要です。"</string>
<string name="text_e009c148d1890dac" formatted="false">"合計"</string>
<string name="text_81c92013219dd115" formatted="false">"同意して続行"</string>
<string name="text_409696774c9998ce" formatted="false">"バックグラウンドで接続を維持し、クリックしてリモート デスクトップに戻ります"</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"バックグラウンドのキープアライブを開始できませんでした。アプリケーションをフォアグラウンドで維持してください。"</string>
<string name="text_d96a21463557171d" formatted="false">"バックグラウンド キープアライブの開始に失敗しました。アプリケーションをフォアグラウンドに保ち、再接続してください。"</string>
<string name="text_952f7300de0a7bd9" formatted="false">"バックグラウンド接続が終了しました。再接続してください。"</string>
<string name="text_da904f715e1cff87" formatted="false">"バックグラウンド接続サービスが停止しました。再接続してください。"</string>
<string name="text_0812359e18082071" formatted="false">"新しいプレビューはその後の接続でも保存されます。"</string>
<string name="text_b9086662b1df1f20" formatted="false">"オンライン"</string>
<string name="text_39f2f444a042643e" formatted="false">"プロトコルとパスを除いて、ホスト名または IPv4 アドレスを入力します。"</string>
<string name="text_473400815c0f687d" formatted="false">"サウンド"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"リモートテキストをコピーする"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"帯域幅が不十分な場合は、常に高い解像度を維持し、フレーム レートを下げて画像の鮮明さを確保します。"</string>
<string name="text_7ff73de85ec16d15" formatted="false">"メディアの暗号化"</string>
<string name="text_22dc6217af77bc37" formatted="false">"英数字"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"安全なストレージまたは証明書の初期化に失敗しました。再試行するか、アプリケーション データをクリアしてください。"</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"完了"</string>
<string name="text_678328f6f02881c7" formatted="false">"プライバシー認証を完了すると、アップデートを確認できます"</string>
<string name="text_c4361f9abdba4882" formatted="false">"プライバシー認証完了後、ローカル ID を登録します"</string>
<string name="text_c29b888e357d727d" formatted="false">"パスワードの保存に失敗しました。この接続はまだ使用できます"</string>
<string name="text_3f31300a23afb390" formatted="false">"パスワードは暗号化されてネイティブ Android キーストアに保存されます"</string>
<string name="text_8d3b052c4c67acda" formatted="false">"リモートID"</string>
<string name="text_0512084e130984bc" formatted="false">"ピアID "</string>
<string name="text_c5147d65960196b0" formatted="false">"ピアID{0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"本機に保存されているリモートプレビューはすべて削除され、接続記録とパスワードは保持されます。"</string>
<string name="text_60cc99cc0013972e" formatted="false">"画面の向き"</string>
<string name="text_9e7294d264afecb7" formatted="false">"リモコンメニューを展開する"</string>
<string name="text_9343612ee473edd6" formatted="false">"通知によりリモート接続が切断されました"</string>
<string name="text_9d32476fe83aef60" formatted="false">"パスワード保存済み"</string>
<string name="text_1cbd391383283924" formatted="false">"保存したパスワードが読み取れないため、再入力してください。"</string>
<string name="text_6792b9f341380c68" formatted="false">"保存したパスワードは保持できませんので、再度入力してください。"</string>
<string name="text_74d3f0927d465047" formatted="false">"Ctrl+Alt+Delを送信しました"</string>
<string name="text_c28d86acbedba3b6" formatted="false">"バックグラウンドキープアライブが有効になり、アプリケーションを切り替えて接続を維持し続けることができます。"</string>
<string name="text_506409ec08cb2a79" formatted="false">"バックグラウンドキープアライブを有効にして、接続通知を返すか接続を切断することができます"</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"認められる"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"読む"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"接続されました "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"サーバーに接続しました"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"フレームレート"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"滑らかさ"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"バランス"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"アプリケーション内のライセンス ファイルが見つからないか破損しています。アプリケーションを再インストールしてください。"</string>
<string name="text_3f2904435942cc0f" formatted="false">"開発バージョンには、まだリリースされていない修正が含まれています。以下のリンクは基本バージョンのソースコードです。"</string>
<string name="text_4338afaad52ea445" formatted="false">"保存を有効にする"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"プライバシー画面を有効にする"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"オープンソースコンポーネント"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"オープンソース ソフトウェアの権利の説明"</string>
<string name="text_6e86570183c3cdd0" formatted="false">"これが現在の最新バージョンです"</string>
<string name="text_24a52788cf474bf6" formatted="false">"現在、リモート エンドはこのテキストの直接入力をサポートしていません。テキスト入力をサポートするバージョンのコントロール エンドを使用するか、クリップボード経由で送信してください。"</string>
<string name="text_33f25b500697a7d9" formatted="false">"接続に成功すると、最も近いリモート デバイスがここに表示されます。"</string>
<string name="text_70341256d7317e7d" formatted="false">"プライバシーポリシーを読んで同意します"</string>
<string name="text_85c4c8fda0461f35" formatted="false">"選択したファイル(読み込み不可、または容量不足)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"デバイスIDが見つかりませんでした"</string>
<string name="text_97b7b18df07b9787" formatted="false">"コマンドの送信に失敗しました。もう一度お試しください。"</string>
<string name="text_cb782027ca5986c7" formatted="false">"コマンドが送信され、コンピューターの確認を待っています..."</string>
<string name="text_3ca40a586103f6b7" formatted="false">"認可ステータス"</string>
<string name="text_6e684586884ebee6" formatted="false">"受信"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"受け取る "</string>
<string name="text_d16488b987723161" formatted="false">"ローカルデコーダ"</string>
<string name="text_6b58198955259066" formatted="false">"撤回"</string>
<string name="text_c52e42ee856825db" formatted="false">"退会後はリモート接続が停止され、画面プレビューが閉じられ、既存のプレビューが消去されます。接続記録とパスワードは保持され、再接続する前に再度認証が必要になります。"</string>
<string name="text_61f4788032ec596b" formatted="false">"ネットワーク認証の撤回"</string>
<string name="text_e736e419008ac10a" formatted="false">"ネットワーク認証を取り消しますか?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"リモコンメニューを折りたたむ"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"キーボードを隠す"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"マウスを隠す"</string>
<string name="text_5440f7424f2865bf" formatted="false">"データ"</string>
<string name="text_94244f41d0a41378" formatted="false">"ファイルの保存に失敗しました。もう一度お試しください。"</string>
<string name="text_2553a785fef2a405" formatted="false">"ファイルを保存しました"</string>
<string name="text_d573b3f9a810ea04" formatted="false">"ファイル選択がタイムアウトし、セッションが終了しました"</string>
<string name="text_985a5c380be49f4f" formatted="false">"テキストが128KBを超えています"</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"切断"</string>
<string name="text_09b1395f50589327" formatted="false">"切断後ホームページに戻ります"</string>
<string name="text_a73c0680db0d2e06" formatted="false">"リモート接続を切断しますか?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"切断"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"新しいバージョン"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"利用可能な新しいバージョン: v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"利用可能な新しいバージョン: v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"未読のお知らせはありません"</string>
<string name="text_dca2c418ac967366" formatted="false">"読み取りステータスをローカルに保存できません。もう一度試してください。"</string>
<string name="text_5c92da76360056cc" formatted="false">"このお知らせは削除できません。もう一度試してください。"</string>
<string name="text_615709dd3129313c" formatted="false">"リモート接続を開始できません"</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"オープンソースライセンスを読み取れません"</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"ディスプレイ"</string>
<string name="text_ffe21a671190323e" formatted="false">"パスワードを表示"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"今は同意しない"</string>
<string name="text_6d9a7758610a2432" formatted="false">"アナウンスはありません"</string>
<string name="text_0b290c6600d92f43" formatted="false">"最近の接続"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"サーバー"</string>
<string name="text_dfe408e686175095" formatted="false">"ServerTLS証明書の検証に失敗しました"</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"サーバーアドレス"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"サーバーへのログインに失敗しました。サーバーのアドレスとネットワークを確認してください。"</string>
<string name="text_d270868960e68629" formatted="false">"サーバーポート"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"サーバー接続がタイムアウトしました。サーバーのアドレスとネットワークを確認してください。"</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"有効ではありません"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"リンクを開くブラウザが見つかりませんでした"</string>
<string name="text_94bc3d40defef3e9" formatted="false">"無許可"</string>
<string name="text_4d0680f9efaef147" formatted="false">"未読"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"サーバーが接続されていません · クリックして再試行してください"</string>
<string name="text_7eea7966c0b746c5" formatted="false">"このアプリケーションは、限定保証を提供し、責任を制限するCeCILL-C1.0 に基づく前方誤り訂正にOpenFECを使用します。詳細については、完全なライセンスを参照してください。"</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"ローカルID "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"ローカルID{0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"このバージョンのソース コードとビルド手順"</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"このビルドではソース リビジョンが利用できません。"</string>
<string name="text_f0e274aa130e08a2" formatted="false">"放してリフレッシュ"</string>
<string name="text_47077144715b7074" formatted="false">"組み立て説明書"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"ビルドレシピとプロジェクトパッチ"</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"ソースコードを表示する"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"チェックに失敗しました。後でもう一度試してください。"</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"アップデートを確認する"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"プライバシー画面を閉じています…"</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"安全な接続を確立中…"</string>
<string name="text_0da575e3c92de73a" formatted="false">"プライバシー画面を有効化中…"</string>
<string name="text_91834aebecabcbc3" formatted="false">"アップデートを確認しています…"</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"クリア中..."</string>
<string name="text_0e6336c8fe918730" formatted="false">"ローカルIDを取得中..."</string>
<string name="text_faebd20c2801fd0f" formatted="false">"選択したファイルを読み込み中..."</string>
<string name="text_694186911ae3e4da" formatted="false">"接続する"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"シグナリングサーバーに接続しています..."</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"サーバーに接続中..."</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"サーバーに再接続中..."</string>
<string name="text_4e4fce649fc00238" formatted="false">"このデバイスでは使用できるファイル ピッカーがありません"</string>
<string name="text_bce2377283c2455a" formatted="false">"クリア"</string>
<string name="text_ee0e76b8507212de" formatted="false">"プレビューを削除"</string>
<string name="text_ecc809e44637de18" formatted="false">"プレビューを削除しますか?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"ソースコードとビルド手順"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"ソースコードのバージョン"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"バージョン"</string>
<string name="text_16410ee9663fad10" formatted="false">"著作権およびライセンスに関する声明"</string>
<string name="text_11de77e1e41e680f" formatted="false">"コンピューターのキーボード"</string>
<string name="text_20f57900234debd7" formatted="false">"画質"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"ビデオ設定"</string>
<string name="text_82a5600c470179fa" formatted="false">"映像遅延"</string>
<string name="text_33b458faaf967e15" formatted="false">"ビデオ設定"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"ビデオ品質"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"ビデオキャプチャフレームレート"</string>
<string name="text_66062971234f422d" formatted="false">"トラックパッド"</string>
<string name="text_c421dc051bea3f11" formatted="false">"相対マウス"</string>
<string name="text_de32e20193ad1042" formatted="false">"分かりました"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"OK"</string>
<string name="text_36f33adaf0942634" formatted="false">"確認する"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"接続を確認する"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"オフライン"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"スペース"</string>
<string name="text_471a579e8d87faac" formatted="false">"ポート範囲は 1 ~ 65535 です。"</string>
<string name="text_c7d0c77862e32b52" formatted="false">"記号・機能"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"コンピューターがプライバシー画面の状態に戻るまで待ちます。古いコンピューターではサポートされていない可能性があります。"</string>
<string name="text_5ee880e2524a983a" formatted="false">"リモート画面情報待ち"</string>
<string name="text_674c723a85ee7f57" formatted="false">"リモート画面を待っています..."</string>
<string name="text_119f0483da4756c4" formatted="false">"プライバシー認証待ち"</string>
<string name="text_7916071c1a8ee458" formatted="false">"システムキーボード"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"直接タッチ"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"アブソリュートマウス"</string>
<string name="text_872428a7210d5e0c" formatted="false">"ネットワーク"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"カスタム"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"仮想マウス"</string>
<string name="text_62c199f37bf95869" formatted="false">"ローカル ショートカットを使用してリモート プライバシー画面のロックが解除されました。再度ロックするとプライバシーが回復され、ローカル ショートカットのロック解除が無効になります。"</string>
<string name="text_a569ce729c1d6e19" formatted="false">"管理されているバージョンが低すぎるため、最新バージョンにアップグレードしてください。"</string>
<string name="text_d814476826827b78" formatted="false">"リモートエンコーダー"</string>
<string name="text_488402883e27f6f0" formatted="false">"相手側に送信するテキスト"</string>
<string name="text_c20f7618d330a854" formatted="false">"ビデオ"</string>
<string name="text_310c625425f014d8" formatted="false">"ライセンス"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"ライセンスと著作権表示"</string>
<string name="text_e24134b643a17975" formatted="false">"ライセンスと著作権表示はオフラインでも利用できます。"</string>
<string name="text_5e7628f043ce71d0" formatted="false">"デバイス ID を保存できませんでした。安全なストレージを確認してください。"</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"デバイス ID と選択された保存されたアクセス パスワードは、Android キーストアを使用して暗号化されます。接続記録はローカルにのみ保存され、デバイスのバックアップには含まれません。クリップボードの内容はセッションが終了するとクリアされます。アプリを終了した後もピクチャーインピクチャーまたは接続された通知でセッションを維持し、アプリまたは通知内で切断します。"</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"設定"</string>
<string name="text_4f8771952222a011" formatted="false">"設定、新しいバージョンがあります"</string>
<string name="text_f4f712d5be91721a" formatted="false">"アクセスパスワード"</string>
<string name="text_dd6c465847af1b60" formatted="false">"アクセスパスワードが間違っていますので、再度入力してください。"</string>
<string name="text_defba1d2989bfab4" formatted="false">"証明書の検証に失敗しました · クリックして再試行してください"</string>
<string name="text_44f3d4d52347095a" formatted="false">"最初にオンライン認証を完了してください"</string>
<string name="text_2a07ca002bc5093d" formatted="false">"リモート エンドでCrossDeskシステム サービスを有効にして、再試行してください。"</string>
<string name="text_990fd23c62de5ccf" formatted="false">"リクエストがタイムアウトし、コンピュータのプライバシー画面の状態が確認できませんでした。"</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"現在のファイルが送信されるまでお待ちください。"</string>
<string name="text_9305d9d72b06110d" formatted="false">"1 ~ 65535 の範囲でポートを入力してください"</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"9桁のデバイスIDを入力"</string>
<string name="text_285a0714894d2899" formatted="false">"有効なアクセスパスワードを入力してください"</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"設定を適用できませんでした。もう一度やり直してください。"</string>
<string name="text_a762e7e650e285fa" formatted="false">"ソフトウェアライセンス"</string>
<string name="text_572cf45ba43634b3" formatted="false">"戻る"</string>
<string name="text_7565f94031582c7c" formatted="false">"コントロールバーに戻る"</string>
<string name="text_55113b6925c362fd" formatted="false">"まだ接続実績がありません"</string>
<string name="text_3aa527b2106cc987" formatted="false">"このお知らせは更新または削除されました。表示するにはお知らせリストに戻ってください。"</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"リモートタスクが終了しました"</string>
<string name="text_c255841f68096d3b" formatted="false">"リモートデスクトップ"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"リモートデスクトップタッチエリア"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"リモート デスクトップ: タッチしてクリック、ドラッグして移動、右ボタンを押したまま、ピンチしてズーム、ズームインして 2 本の指で画面をドラッグ、2 本の指で上下にスライドして元のスケールでスクロール"</string>
<string name="text_3535087994d050de" formatted="false">"リモートコンピュータ"</string>
<string name="text_ed2e03072ce131de" formatted="false">"リモート ウィンドウが閉じられたか、バックグラウンド キープアライブの開始に失敗し、セッションが終了しました。"</string>
<string name="text_678b783fb578172b" formatted="false">"リモート接続"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"リモートパスワードが正しくありません"</string>
<string name="text_a2ce499295e358ae" formatted="false">"リモートエンドが接続を閉じました"</string>
<string name="text_176e117df3ace734" formatted="false">"リモートエンドは現在利用できません"</string>
<string name="text_d2c0694243f93249" formatted="false">"リモートアクセスパスワードの有効期限が切れています。再接続してください。"</string>
<string name="text_a5574109f0208e89" formatted="false">"接続"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"接続 "</string>
<string name="text_89c4766afc85125f" formatted="false">"接続に失敗しました"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"接続に失敗しました · クリックして再試行してください"</string>
<string name="text_2ab7d89557e779ab" formatted="false">"接続が失われました"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"接続が終了しました"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"接続遅延 (RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"サポートされているリモート デバイスに接続されている場合に使用できます。"</string>
<string name="text_2123a9ed718f2c40" formatted="false">"接続完了後、横画面をロックする"</string>
<string name="text_f741e0536210eafa" formatted="false">"接続プロンプト"</string>
<string name="text_485a26050ce57431" formatted="false">"接続方法"</string>
<string name="text_5a048351a77fe88d" formatted="false">"サーバーに接続する"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"サーバーに接続して最新のお知らせを取得します。"</string>
<string name="text_32cd27b5a72515f3" formatted="false">"接続がタイムアウトしました。デバイス ID、サーバー、リモートのオンライン ステータスを確認してください。"</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"リモートデスクトップに接続する"</string>
<string name="text_d5407b4f3c666681" formatted="false">"バックスペース"</string>
<string name="text_e02926784fcedb91" formatted="false">"お知らせ"</string>
<string name="text_465a092680d132b3" formatted="false">"一部のプレビューをクリアできませんでした。もう一度お試しください。"</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"もう一度お試しください"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"読み取りステータスの保存をリトライする"</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"ロック"</string>
<string name="text_864200bbb241e067" formatted="false">"キーボード"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"読んで承認する"</string>
<string name="text_86651d17a401c55b" formatted="false">"プライバシー"</string>
<string name="text_26c6e011e343708c" formatted="false">"プライバシーと認証"</string>
<string name="text_6cad42fd3da85585" formatted="false">"プライバシー画面"</string>
<string name="text_e157fafb76aad954" formatted="false">"プライバシー画面:オフ"</string>
<string name="text_bbef0eac0c04f214" formatted="false">"プライバシー画面:オン"</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"プライバシー画面のロックが解除されました"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"プライバシー画面異常"</string>
<string name="text_598a2747dbd21dd5" formatted="false">"プライバシー画面が一時的に利用できなくなります"</string>
<string name="text_4d59be3c75189032" formatted="false">"プライバシー画面のステータスが期限切れになり、コンピュータの確認を待っています"</string>
<string name="text_1674532c70ed1db2" formatted="false">"プライバシー画面のステータスを確認する"</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"プライバシーポリシー"</string>
<string name="text_554e957e8e3c00af" formatted="false">"一時的にプライバシーポリシーが閲覧できなくなります。インターネットへの接続を拒否し、アプリケーションを再インストールしてください。"</string>
<string name="text_7db2e189ecb01065" formatted="false">"ミュート"</string>
<string name="text_296c632ec857a0ba" formatted="false">"オーディオ"</string>
<string name="text_0cadb49096a382e0" formatted="false">"プロジェクトWebサイト"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"プレビューには、リモート コンピューターからの個人情報または仕事情報が含まれる場合があります。オンにすると、接続するたびにマシンに写真が自動的に保存され、最近の接続で表示されます。いつでも閉じてクリアできます。"</string>
<string name="text_b1c27820fec23edb" formatted="false">"高"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"デフォルト"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"デフォルトではオフです。オンにすると、電話が接続されているときに画面が横向きになり、横向きのままになります。オフにすると、デバイスの方向に従います。タブレットやマルチウィンドウは影響を受けません。"</string>
<string name="text_1184a97edd8e1646" formatted="false">"マウスの右ボタンを押してドラッグし、放してドラッグを終了します"</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"マウスの左ボタンを押してドラッグし、放してドラッグを終了します"</string>
<string name="text_269f39a07b4a4e01" formatted="false">"マウスコントロール"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"マウスホイール、上下にスライドしてキー範囲外にスライドします"</string>
<string name="text_9aa238fc6121dede" formatted="false">"、パケットロス率 "</string>
<string name="text_0b5d16c21095fe10" formatted="false">"、送信します "</string>
<string name="text_ba9a792d063a8c86" formatted="false">"、パスワードが保存されました"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">"、受け取ります "</string>
<string name="text_42237b5ec70c26f3" formatted="false">"、「再試行」をクリックします"</string>
</resources>
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" · 완료"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" 전송 실패"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" 장치"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" 기사 · "</string>
<string name="text_2c894057fc526576" formatted="false">" 읽지 않은 항목"</string>
<string name="text_d0ac753c6681e578" formatted="false">"CrossDesk원격접속"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL 버전 3"</string>
<string name="text_5ae7dc4079223967" formatted="false">"OpenFEC공지"</string>
<string name="text_69ad29820a259526" formatted="false">"OpenFEC라이선스 및 소스"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"P2P 직접 연결"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRTP 활성화됨"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"TURN 릴레이"</string>
<string name="text_ebede975edd52aee" formatted="false">"{0}전송 실패"</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"기기: {0}"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"{0}읽지 않은 항목"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0}, 수신{1}, 송신{2}, 패킷 손실률{3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● 연결됨"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"아래로 당겨 새로고침"</string>
<string name="text_37772187ab854191" formatted="false">"손실률"</string>
<string name="text_a567bdaa11367f26" formatted="false">"중간"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"영상을 부드럽게 유지하는 데 우선 순위를 두며 대역폭이나 인코딩 압력이 높을 때 해상도를 더욱 적극적으로 줄입니다."</string>
<string name="text_aa9e366f68d3d097" formatted="false">"낮음"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"커스텀 서버 이용하기"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"비밀번호 저장"</string>
<string name="text_10019cef593819ed" formatted="false">"수신된 파일 저장"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"원격 화면 미리보기 저장"</string>
<string name="text_0f197074373e38fd" formatted="false">"원격 화면 미리보기를 저장하시겠습니까?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"신호 연결이 중단되었습니다. 다시 연결하십시오."</string>
<string name="text_789f22a8b1b34797" formatted="false">"공지사항 로딩에 실패했습니다. 다시 시도해주세요."</string>
<string name="text_00554b17c432937e" formatted="false">"공지사항"</string>
<string name="text_2428420e407fe2e8" formatted="false">"합계 "</string>
<string name="text_d977c7568cc5201e" formatted="false">"총{0}항목 ·{1}읽지 않은 항목"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"소개"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"CrossDesk 정보"</string>
<string name="text_390a95b65eb33052" formatted="false">"새 버전이 있습니다."</string>
<string name="text_3fd47edce45b3603" formatted="false">"닫기"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"컨트롤 바 닫기"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"개인 정보 보호 화면 비활성화"</string>
<string name="text_7d466b69be42fc43" formatted="false">"선명도와 부드러움의 균형을 유지하며 일정한 압력을 가하면 해상도가 점차 감소합니다."</string>
<string name="text_b92421822c213c2b" formatted="false">"해상도"</string>
<string name="text_2f9daa828907b93f" formatted="false">"삭제"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"공지사항 삭제"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"기록 삭제"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"이 공지를 삭제하시겠습니까?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"새로고침 안내"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"클립보드"</string>
<string name="text_4927a53bcc886afb" formatted="false">"로드 중…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"더 로드하기"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"업데이트 안내"</string>
<string name="text_861521c9e107c648" formatted="false">"기본 연결 초기화 실패"</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"송신"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"보내기 "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"전송 후 원격 키보드에서 \"붙여넣기\"를 클릭하세요. 수신된 원격 텍스트를 로컬 시스템에 수동으로 복사할 수 있습니다."</string>
<string name="text_e7281fbca7af369c" formatted="false">"파일 보내기"</string>
<string name="text_ce6c02d19022e016" formatted="false">"문자 보내기"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"취소"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"연결 취소"</string>
<string name="text_72814b15a6fa152c" formatted="false">"각 구성 요소에는 나열된 오픈 소스 라이선스가 적용되며 부여된 권리에는 이 애플리케이션의 일반 사용 약관이 적용되지 않습니다. 소스 코드를 보려면 인터넷 연결이 필요합니다."</string>
<string name="text_e009c148d1890dac" formatted="false">"합계"</string>
<string name="text_81c92013219dd115" formatted="false">"동의하고 계속하기"</string>
<string name="text_409696774c9998ce" formatted="false">"백그라운드에서 연결을 유지하고 원격 데스크톱으로 돌아가려면 클릭하세요."</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"백그라운드 연결 유지를 시작하지 못했습니다. 애플리케이션을 포그라운드에 유지하세요."</string>
<string name="text_d96a21463557171d" formatted="false">"백그라운드 연결 유지를 시작하지 못했습니다. 애플리케이션을 포그라운드에 유지하고 다시 연결하세요."</string>
<string name="text_952f7300de0a7bd9" formatted="false">"백그라운드 연결이 종료되었습니다. 다시 연결해 주세요."</string>
<string name="text_da904f715e1cff87" formatted="false">"백그라운드 연결 서비스가 중지되었습니다. 다시 연결해 주세요."</string>
<string name="text_0812359e18082071" formatted="false">"새로운 미리보기는 후속 연결에도 계속 저장됩니다."</string>
<string name="text_b9086662b1df1f20" formatted="false">"온라인"</string>
<string name="text_39f2f444a042643e" formatted="false">"프로토콜 및 경로 없이 호스트 이름 또는 IPv4 주소를 입력하세요."</string>
<string name="text_473400815c0f687d" formatted="false">"소리"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"원격 텍스트 복사"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"대역폭이 부족할 때 영상 선명도를 보장하려면 항상 더 높은 해상도를 유지하고 프레임 속도를 낮추십시오."</string>
<string name="text_7ff73de85ec16d15" formatted="false">"미디어 암호화"</string>
<string name="text_22dc6217af77bc37" formatted="false">"문자·숫자"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"보안 저장 또는 인증서 초기화에 실패했습니다. 다시 시도하거나 애플리케이션 데이터를 삭제하세요."</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"완료"</string>
<string name="text_678328f6f02881c7" formatted="false">"개인정보 동의 완료 후 업데이트 내용을 확인할 수 있습니다."</string>
<string name="text_c4361f9abdba4882" formatted="false">"개인정보 인증 완료 후 로컬 신원 등록"</string>
<string name="text_c29b888e357d727d" formatted="false">"비밀번호 저장에 실패했습니다. 이 연결은 계속 사용할 수 있습니다."</string>
<string name="text_3f31300a23afb390" formatted="false">"비밀번호는 기본 Android 키 저장소에 암호화되어 저장됩니다."</string>
<string name="text_8d3b052c4c67acda" formatted="false">"원격 ID"</string>
<string name="text_0512084e130984bc" formatted="false">"피어 ID "</string>
<string name="text_c5147d65960196b0" formatted="false">"피어 ID{0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"이 기기에 저장된 원격 미리보기는 모두 삭제되며, 연결 기록과 비밀번호는 유지됩니다."</string>
<string name="text_60cc99cc0013972e" formatted="false">"화면 방향"</string>
<string name="text_9e7294d264afecb7" formatted="false">"원격제어 메뉴 확장"</string>
<string name="text_9343612ee473edd6" formatted="false">"알림으로 인해 원격 연결이 끊어졌습니다."</string>
<string name="text_9d32476fe83aef60" formatted="false">"비밀번호 저장됨"</string>
<string name="text_1cbd391383283924" formatted="false">"저장된 비밀번호를 읽을 수 없습니다. 다시 입력해 주세요."</string>
<string name="text_6792b9f341380c68" formatted="false">"저장된 비밀번호는 보관할 수 없으므로 다시 입력해 주세요."</string>
<string name="text_74d3f0927d465047" formatted="false">"Ctrl+Alt+Del가 전송되었습니다."</string>
<string name="text_c28d86acbedba3b6" formatted="false">"백그라운드 연결 유지가 활성화되어 전환 애플리케이션이 계속 연결을 유지할 수 있습니다."</string>
<string name="text_506409ec08cb2a79" formatted="false">"연결 알림에서 돌아가거나 연결을 끊기 위해 백그라운드 연결 유지가 활성화되었습니다."</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"승인됨"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"읽기"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"연결됨 "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"서버에 연결됨"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"프레임 속도"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"부드러움"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"균형"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"애플리케이션의 라이센스 파일이 없거나 손상되었습니다. 애플리케이션을 다시 설치하십시오."</string>
<string name="text_3f2904435942cc0f" formatted="false">"개발 버전에는 아직 출시되지 않은 수정 사항이 포함되어 있습니다. 다음 링크는 기본 버전의 소스 코드입니다."</string>
<string name="text_4338afaad52ea445" formatted="false">"저장 사용"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"개인 정보 보호 화면 활성화"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"오픈 소스 구성 요소"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"오픈소스 소프트웨어 권리 설명"</string>
<string name="text_6e86570183c3cdd0" formatted="false">"현재 최신 버전입니다."</string>
<string name="text_24a52788cf474bf6" formatted="false">"현재 원격측에서는 이 텍스트를 직접 입력하는 기능을 지원하지 않습니다. 텍스트 입력을 지원하는 제어된 버전을 사용하거나 클립보드를 통해 보내주세요."</string>
<string name="text_33f25b500697a7d9" formatted="false">"성공적으로 연결되면 가장 가까운 원격 장치가 여기에 표시됩니다."</string>
<string name="text_70341256d7317e7d" formatted="false">"개인정보 보호정책을 읽었으며 이에 동의합니다."</string>
<string name="text_85c4c8fda0461f35" formatted="false">"선택한 파일(읽을 수 없거나 공간이 부족함)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"장치 ID를 찾을 수 없습니다"</string>
<string name="text_97b7b18df07b9787" formatted="false">"명령 전송에 실패했습니다. 다시 시도해 주세요."</string>
<string name="text_cb782027ca5986c7" formatted="false">"명령이 전송되었으며 컴퓨터 확인을 기다리고 있습니다..."</string>
<string name="text_3ca40a586103f6b7" formatted="false">"인증상태"</string>
<string name="text_6e684586884ebee6" formatted="false">"수신"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"받다 "</string>
<string name="text_d16488b987723161" formatted="false">"로컬 디코더"</string>
<string name="text_6b58198955259066" formatted="false">"철회"</string>
<string name="text_c52e42ee856825db" formatted="false">"탈퇴 후에는 원격접속이 중단되며 화면 미리보기가 닫히고 기존 미리보기는 지워집니다. 접속 기록과 비밀번호는 유지되며, 재접속 전 다시 인증이 필요합니다."</string>
<string name="text_61f4788032ec596b" formatted="false">"네트워크 승인 철회"</string>
<string name="text_e736e419008ac10a" formatted="false">"네트워크 승인을 철회하시겠습니까?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"리모컨 메뉴 접기"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"키보드 숨기기"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"마우스 숨기기"</string>
<string name="text_5440f7424f2865bf" formatted="false">"데이터"</string>
<string name="text_94244f41d0a41378" formatted="false">"파일 저장에 실패했습니다. 다시 시도해 주세요."</string>
<string name="text_2553a785fef2a405" formatted="false">"파일이 저장되었습니다"</string>
<string name="text_d573b3f9a810ea04" formatted="false">"파일 선택 시간이 초과되어 세션이 종료되었습니다."</string>
<string name="text_985a5c380be49f4f" formatted="false">"텍스트가 128KB를 초과합니다."</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"연결 끊기"</string>
<string name="text_09b1395f50589327" formatted="false">"연결을 끊으면 홈페이지로 돌아갑니다."</string>
<string name="text_a73c0680db0d2e06" formatted="false">"원격 연결을 끊으시겠습니까?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"연결 해제"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"새 버전"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"새 버전 사용 가능: v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"새 버전 사용 가능: v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"읽지 않은 공지사항 없음"</string>
<string name="text_dca2c418ac967366" formatted="false">"읽기 상태를 로컬에 저장할 수 없습니다. 다시 시도해 주세요."</string>
<string name="text_5c92da76360056cc" formatted="false">"이 공지를 삭제할 수 없습니다. 다시 시도해 주세요."</string>
<string name="text_615709dd3129313c" formatted="false">"원격 연결을 시작할 수 없습니다"</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"오픈소스 라이선스를 읽을 수 없습니다."</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"디스플레이"</string>
<string name="text_ffe21a671190323e" formatted="false">"비밀번호 표시"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"나중에"</string>
<string name="text_6d9a7758610a2432" formatted="false">"공지 없음"</string>
<string name="text_0b290c6600d92f43" formatted="false">"최근 연결"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"서버"</string>
<string name="text_dfe408e686175095" formatted="false">"ServerTLS인증서 확인 실패"</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"서버 주소"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"서버 로그인에 실패했습니다. 서버 주소와 네트워크를 확인해주세요."</string>
<string name="text_d270868960e68629" formatted="false">"서버 포트"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"서버 연결 시간이 초과되었습니다. 서버 주소와 네트워크를 확인하세요."</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"활성화되지 않음"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"링크를 열 수 있는 브라우저가 없습니다."</string>
<string name="text_94bc3d40defef3e9" formatted="false">"무단"</string>
<string name="text_4d0680f9efaef147" formatted="false">"읽지 않음"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"서버가 연결되지 않았습니다. · 다시 시도하려면 클릭하세요."</string>
<string name="text_7eea7966c0b746c5" formatted="false">"이 애플리케이션은 제한된 보증과 책임 제한을 제공하는CeCILL-C1.0에 따라 순방향 오류 수정을 위해OpenFEC를 사용합니다. 자세한 내용은 전체 라이센스를 참조하세요."</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"로컬 ID "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"로컬 ID{0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"이 버전의 소스 코드 및 빌드 지침"</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"이 빌드에는 소스 개정을 사용할 수 없습니다."</string>
<string name="text_f0e274aa130e08a2" formatted="false">"새로고침하려면 릴리즈하세요"</string>
<string name="text_47077144715b7074" formatted="false">"빌드 지침"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"레시피 및 프로젝트 패치 빌드"</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"소스코드 보기"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"확인에 실패했습니다. 나중에 다시 시도해 주세요."</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"업데이트 확인"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"개인정보 보호 화면을 닫는 중…"</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"보안 연결 설정 중…"</string>
<string name="text_0da575e3c92de73a" formatted="false">"개인정보 보호 화면을 켜는 중..."</string>
<string name="text_91834aebecabcbc3" formatted="false">"업데이트 확인 중…"</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"지우는 중..."</string>
<string name="text_0e6336c8fe918730" formatted="false">"로컬 ID를 얻는 중..."</string>
<string name="text_faebd20c2801fd0f" formatted="false">"선택한 파일을 읽는 중..."</string>
<string name="text_694186911ae3e4da" formatted="false">"연결 중"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"시그널링 서버에 연결 중..."</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"서버에 연결하는 중..."</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"서버에 재접속하는 중..."</string>
<string name="text_4e4fce649fc00238" formatted="false">"이 장치에 사용할 수 있는 파일 선택기가 없습니다."</string>
<string name="text_bce2377283c2455a" formatted="false">"클리어"</string>
<string name="text_ee0e76b8507212de" formatted="false">"미리보기 삭제"</string>
<string name="text_ecc809e44637de18" formatted="false">"미리보기를 삭제할까요?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"소스 코드 및 빌드 지침"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"소스코드 버전"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"버전"</string>
<string name="text_16410ee9663fad10" formatted="false">"저작권 및 라이선스 정책"</string>
<string name="text_11de77e1e41e680f" formatted="false">"컴퓨터 키보드"</string>
<string name="text_20f57900234debd7" formatted="false">"화질"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"영상 선호도"</string>
<string name="text_82a5600c470179fa" formatted="false">"영상 지연"</string>
<string name="text_33b458faaf967e15" formatted="false">"영상 설정"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"비디오 품질"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"비디오 캡처 프레임 속도"</string>
<string name="text_66062971234f422d" formatted="false">"트랙패드"</string>
<string name="text_c421dc051bea3f11" formatted="false">"상대 마우스"</string>
<string name="text_de32e20193ad1042" formatted="false">"알겠습니다"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"알았어"</string>
<string name="text_36f33adaf0942634" formatted="false">"확인"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"연결 확인"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"오프라인"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"스페이스"</string>
<string name="text_471a579e8d87faac" formatted="false">"포트 범위는 1~65535입니다."</string>
<string name="text_c7d0c77862e32b52" formatted="false">"기호·기능"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"컴퓨터가 개인 정보 보호 화면 상태로 돌아갈 때까지 기다립니다. 오래된 컴퓨터에서는 이를 지원하지 않을 수 있습니다."</string>
<string name="text_5ee880e2524a983a" formatted="false">"원격 화면 정보를 기다리는 중"</string>
<string name="text_674c723a85ee7f57" formatted="false">"원격 화면을 기다리는 중..."</string>
<string name="text_119f0483da4756c4" formatted="false">"개인정보 인증 대기 중"</string>
<string name="text_7916071c1a8ee458" formatted="false">"시스템 키보드"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"직접 터치"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"앱솔루트 마우스"</string>
<string name="text_872428a7210d5e0c" formatted="false">"네트워크"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"커스텀"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"가상마우스"</string>
<string name="text_62c199f37bf95869" formatted="false">"로컬 단축키를 사용하여 원격 개인정보 보호 화면을 잠금 해제했습니다. 개인정보 보호를 복원하고 로컬 바로가기 잠금 해제를 비활성화하려면 다시 잠그세요."</string>
<string name="text_a569ce729c1d6e19" formatted="false">"제어 버전이 너무 낮습니다. 최신 버전으로 업그레이드하세요."</string>
<string name="text_d814476826827b78" formatted="false">"리모트 엔코더"</string>
<string name="text_488402883e27f6f0" formatted="false">"원격지로 보낼 텍스트"</string>
<string name="text_c20f7618d330a854" formatted="false">"영상"</string>
<string name="text_310c625425f014d8" formatted="false">"라이센스"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"라이센스 및 저작권 고지"</string>
<string name="text_e24134b643a17975" formatted="false">"라이센스 및 저작권 고지는 오프라인에서 확인할 수 있습니다."</string>
<string name="text_5e7628f043ce71d0" formatted="false">"기기 ID를 저장하지 못했습니다. 보안 저장소를 확인하세요."</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"기기 ID와 선택된 저장된 액세스 비밀번호는 Android Keystore를 사용하여 암호화됩니다. 연결 기록은 로컬에만 저장되며 장치 백업에는 포함되지 않습니다. 세션이 종료되면 클립보드 내용이 지워집니다. 앱을 종료한 후에도 PIP(picture-in-picture) 또는 연결된 알림으로 세션을 유지하고 앱 또는 알림 내에서 연결을 끊습니다."</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"설정"</string>
<string name="text_4f8771952222a011" formatted="false">"설정, 새 버전이 있습니다"</string>
<string name="text_f4f712d5be91721a" formatted="false">"접속 비밀번호"</string>
<string name="text_dd6c465847af1b60" formatted="false">"접속비밀번호가 잘못되었습니다. 다시 입력해 주세요."</string>
<string name="text_defba1d2989bfab4" formatted="false">"인증서 확인 실패 · 다시 시도하려면 클릭하세요."</string>
<string name="text_44f3d4d52347095a" formatted="false">"먼저 온라인 인증을 완료해주세요."</string>
<string name="text_2a07ca002bc5093d" formatted="false">"원격 측에서CrossDesk시스템 서비스를 활성화하고 다시 시도하십시오."</string>
<string name="text_990fd23c62de5ccf" formatted="false">"요청 시간이 초과되어 컴퓨터의 개인 정보 보호 화면 상태가 확인되지 않았습니다."</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"현재 파일이 전송될 때까지 기다려 주십시오."</string>
<string name="text_9305d9d72b06110d" formatted="false">"1~65535 범위의 포트를 입력하세요."</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"기기 ID 9자리를 입력하세요."</string>
<string name="text_285a0714894d2899" formatted="false">"유효한 접속 비밀번호를 입력해주세요"</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"설정을 적용할 수 없습니다. 다시 시도해 보세요."</string>
<string name="text_a762e7e650e285fa" formatted="false">"소프트웨어 라이센스"</string>
<string name="text_572cf45ba43634b3" formatted="false">"뒤로"</string>
<string name="text_7565f94031582c7c" formatted="false">"컨트롤 바로 돌아가기"</string>
<string name="text_55113b6925c362fd" formatted="false">"아직 접속기록이 없습니다"</string>
<string name="text_3aa527b2106cc987" formatted="false">"본 공지사항은 업데이트 또는 삭제되었습니다. 공지사항 목록으로 돌아가서 확인하시기 바랍니다."</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"원격 작업이 종료되었습니다."</string>
<string name="text_c255841f68096d3b" formatted="false">"원격 데스크톱"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"원격 데스크톱 터치 영역"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"원격 데스크톱: 터치하고 클릭하고, 드래그하여 이동하고, 오른쪽 버튼을 길게 누르고, 핀치하여 확대/축소하고, 두 손가락으로 화면을 확대하고 드래그하고, 두 손가락으로 위아래로 밀어 원래 크기로 스크롤합니다."</string>
<string name="text_3535087994d050de" formatted="false">"원격 컴퓨터"</string>
<string name="text_ed2e03072ce131de" formatted="false">"원격 창이 닫혔거나 백그라운드 Keepalive 시작에 실패하여 세션이 종료되었습니다."</string>
<string name="text_678b783fb578172b" formatted="false">"원격 접속"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"원격 비밀번호가 올바르지 않습니다."</string>
<string name="text_a2ce499295e358ae" formatted="false">"원격 측에서 연결을 종료했습니다."</string>
<string name="text_176e117df3ace734" formatted="false">"현재 원격 끝을 사용할 수 없습니다."</string>
<string name="text_d2c0694243f93249" formatted="false">"원격 접속 비밀번호가 만료되었습니다. 다시 접속해 주세요."</string>
<string name="text_a5574109f0208e89" formatted="false">"연결"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"연결 "</string>
<string name="text_89c4766afc85125f" formatted="false">"연결 실패"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"연결 실패 · 다시 시도하려면 클릭하세요."</string>
<string name="text_2ab7d89557e779ab" formatted="false">"연결 끊김"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"연결이 종료되었습니다"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"연결 지연(RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"지원되는 원격 장치에 연결된 경우 사용할 수 있습니다."</string>
<string name="text_2123a9ed718f2c40" formatted="false">"연결 완료 후 가로 화면 잠금"</string>
<string name="text_f741e0536210eafa" formatted="false">"연결 프롬프트"</string>
<string name="text_485a26050ce57431" formatted="false">"연결 방법"</string>
<string name="text_5a048351a77fe88d" formatted="false">"서버에 접속"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"서버에 접속하여 최신 공지사항을 받아보세요."</string>
<string name="text_32cd27b5a72515f3" formatted="false">"연결 시간이 초과되었습니다. 장치 ID, 서버 및 원격 온라인 상태를 확인하십시오."</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"원격 데스크톱에 연결"</string>
<string name="text_d5407b4f3c666681" formatted="false">"백스페이스"</string>
<string name="text_e02926784fcedb91" formatted="false">"공지사항"</string>
<string name="text_465a092680d132b3" formatted="false">"일부 미리보기를 삭제할 수 없습니다. 다시 시도해 주세요."</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"다시 시도해 보세요"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"읽은 상태 저장 다시 시도"</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"잠금"</string>
<string name="text_864200bbb241e067" formatted="false">"키보드"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"읽기 및 승인"</string>
<string name="text_86651d17a401c55b" formatted="false">"개인 정보 보호"</string>
<string name="text_26c6e011e343708c" formatted="false">"개인 정보 보호 및 승인"</string>
<string name="text_6cad42fd3da85585" formatted="false">"개인정보 보호 화면"</string>
<string name="text_e157fafb76aad954" formatted="false">"화면 보호 꺼짐"</string>
<string name="text_bbef0eac0c04f214" formatted="false">"화면 보호 켜짐"</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"개인정보 보호 화면 잠금 해제됨"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"개인정보 화면 이상"</string>
<string name="text_598a2747dbd21dd5" formatted="false">"개인 정보 보호 화면을 일시적으로 사용할 수 없습니다."</string>
<string name="text_4d59be3c75189032" formatted="false">"개인 정보 보호 화면 상태가 만료되어 컴퓨터 확인을 기다리는 중입니다."</string>
<string name="text_1674532c70ed1db2" formatted="false">"개인정보 보호 화면 상태 확인 예정"</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"개인정보 보호정책"</string>
<string name="text_554e957e8e3c00af" formatted="false">"개인정보취급방침은 일시적으로 열람할 수 없습니다. 인터넷 연결을 거부하고 애플리케이션을 다시 설치하십시오."</string>
<string name="text_7db2e189ecb01065" formatted="false">"음소거"</string>
<string name="text_296c632ec857a0ba" formatted="false">"오디오"</string>
<string name="text_0cadb49096a382e0" formatted="false">"프로젝트 홈페이지"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"미리보기에는 원격 컴퓨터의 개인 정보나 업무 정보가 포함될 수 있습니다. 전원을 켜면 각 연결이 자동으로 기기에 사진을 저장하고 최근 연결에 표시됩니다. 언제든지 닫고 지울 수 있습니다."</string>
<string name="text_b1c27820fec23edb" formatted="false">"높음"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"기본값"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"기본적으로 꺼져 있습니다. 전원을 켜면 휴대폰이 연결되면 화면이 가로 방향으로 전환되고 유지됩니다. 꺼지면 장치의 방향을 따릅니다. 태블릿과 멀티 윈도우는 영향을 받지 않습니다."</string>
<string name="text_1184a97edd8e1646" formatted="false">"마우스 오른쪽 버튼을 누르고 드래그한 후 떼면 드래그가 종료됩니다."</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"마우스 왼쪽 버튼을 누르고 드래그한 후 떼면 드래그가 종료됩니다."</string>
<string name="text_269f39a07b4a4e01" formatted="false">"마우스 제어"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"마우스 휠, 위아래로 밀어 키 범위 밖으로 슬라이드"</string>
<string name="text_9aa238fc6121dede" formatted="false">", 패킷 손실률 "</string>
<string name="text_0b5d16c21095fe10" formatted="false">", 보내다 "</string>
<string name="text_ba9a792d063a8c86" formatted="false">", 비밀번호가 저장되었습니다"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", 수신 "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", 재시도를 클릭하세요."</string>
</resources>
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" · Concluído"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" Falha na transferência"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" dispositivos"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" Artigo · "</string>
<string name="text_2c894057fc526576" formatted="false">" itens não lidos"</string>
<string name="text_d0ac753c6681e578" formatted="false">"Conexão remota CrossDesk"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL versão 3"</string>
<string name="text_5ae7dc4079223967" formatted="false">"Aviso OpenFEC"</string>
<string name="text_69ad29820a259526" formatted="false">"Licença e fonte OpenFEC"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"Conexão direta P2P"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRT habilitado"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"GIRAR relé"</string>
<string name="text_ebede975edd52aee" formatted="false">"Falha na transmissão {0}"</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"Dispositivos: {0}"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"Itens não lidos {0}"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0}, recebendo {1}, enviando {2}, taxa de perda de pacotes {3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● Conectado"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"Puxe para baixo para atualizar"</string>
<string name="text_37772187ab854191" formatted="false">"Taxa de perda"</string>
<string name="text_a567bdaa11367f26" formatted="false">"Média"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"Prioriza manter a imagem suave e reduzirá a resolução de forma mais agressiva quando a largura de banda ou a pressão de codificação forem altas."</string>
<string name="text_aa9e366f68d3d097" formatted="false">"Baixa"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"Usando um servidor personalizado"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"Salvar senha"</string>
<string name="text_10019cef593819ed" formatted="false">"Salvar arquivo recebido"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"Salvar visualizações de tela remotas"</string>
<string name="text_0f197074373e38fd" formatted="false">"Salvar visualização remota da tela?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"A conexão de sinalização foi interrompida, reconecte."</string>
<string name="text_789f22a8b1b34797" formatted="false">"Falha ao carregar o anúncio. Tente novamente."</string>
<string name="text_00554b17c432937e" formatted="false">"Detalhes do anúncio"</string>
<string name="text_2428420e407fe2e8" formatted="false">"Total "</string>
<string name="text_d977c7568cc5201e" formatted="false">"Total de itens {0}· Itens não lidos {1}"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"Sobre"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"Sobre o CrossDesk"</string>
<string name="text_390a95b65eb33052" formatted="false">"Sobre, há uma nova versão"</string>
<string name="text_3fd47edce45b3603" formatted="false">"Fechar"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"Fechar barra de controle"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"Desativar tela de privacidade"</string>
<string name="text_7d466b69be42fc43" formatted="false">"Equilibra clareza e suavidade, reduzindo apenas gradualmente a resolução sob pressão constante."</string>
<string name="text_b92421822c213c2b" formatted="false">"Resolução"</string>
<string name="text_2f9daa828907b93f" formatted="false">"Excluir"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"Excluir anúncio"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"Excluir registro"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"Excluir este anúncio?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"Anúncio de atualização"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"Área de transferência"</string>
<string name="text_4927a53bcc886afb" formatted="false">"Carregando…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"Carregue mais"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"Atualização recomendada"</string>
<string name="text_861521c9e107c648" formatted="false">"Falha na inicialização da conexão nativa"</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"Enviar"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"Enviar "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"Após o envio, clique em “Colar” no teclado remoto. O texto remoto recebido pode ser copiado manualmente para a máquina local."</string>
<string name="text_e7281fbca7af369c" formatted="false">"Enviar arquivo"</string>
<string name="text_ce6c02d19022e016" formatted="false">"Enviar texto"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"Cancelar"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"Cancelar conexão"</string>
<string name="text_72814b15a6fa152c" formatted="false">"Cada componente está sujeito à licença de código aberto listada e os direitos concedidos não estão sujeitos aos termos gerais de uso deste aplicativo. A visualização do código-fonte requer uma conexão com a Internet."</string>
<string name="text_e009c148d1890dac" formatted="false">"Total"</string>
<string name="text_81c92013219dd115" formatted="false">"Concorde e continue"</string>
<string name="text_409696774c9998ce" formatted="false">"Mantenha-se conectado em segundo plano, clique para retornar à área de trabalho remota"</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"Falha ao iniciar a manutenção de atividade em segundo plano, mantenha o aplicativo em primeiro plano"</string>
<string name="text_d96a21463557171d" formatted="false">"Falha ao iniciar a manutenção de atividade em segundo plano. Mantenha o aplicativo em primeiro plano e reconecte."</string>
<string name="text_952f7300de0a7bd9" formatted="false">"A conexão em segundo plano foi encerrada. Reconecte."</string>
<string name="text_da904f715e1cff87" formatted="false">"O serviço de conexão em segundo plano foi interrompido, reconecte."</string>
<string name="text_0812359e18082071" formatted="false">"Novas visualizações ainda serão salvas nas conexões subsequentes."</string>
<string name="text_b9086662b1df1f20" formatted="false">"On-line"</string>
<string name="text_39f2f444a042643e" formatted="false">"Preencha o nome do host ou endereço IPv4, sem protocolo e caminho"</string>
<string name="text_473400815c0f687d" formatted="false">"Som"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"Copiar texto remoto"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"Sempre mantenha uma resolução mais alta e reduza a taxa de quadros para garantir a clareza da imagem quando a largura de banda for insuficiente."</string>
<string name="text_7ff73de85ec16d15" formatted="false">"Criptografia de mídia"</string>
<string name="text_22dc6217af77bc37" formatted="false">"Letras e números"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"Falha no armazenamento seguro ou na inicialização do certificado. Tente novamente ou limpe os dados do aplicativo"</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"Feito"</string>
<string name="text_678328f6f02881c7" formatted="false">"Você pode verificar se há atualizações após concluir a autorização de privacidade"</string>
<string name="text_c4361f9abdba4882" formatted="false">"Registre a identidade local após concluir a autorização de privacidade"</string>
<string name="text_c29b888e357d727d" formatted="false">"Falha ao salvar a senha, esta conexão ainda pode ser usada"</string>
<string name="text_3f31300a23afb390" formatted="false">"As senhas são salvas criptografadas no Android Keystore nativo"</string>
<string name="text_8d3b052c4c67acda" formatted="false">"ID remoto"</string>
<string name="text_0512084e130984bc" formatted="false">"ID do par "</string>
<string name="text_c5147d65960196b0" formatted="false">"ID de par {0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"Todas as visualizações remotas salvas nesta máquina serão excluídas e os registros de conexão e senhas serão mantidos."</string>
<string name="text_60cc99cc0013972e" formatted="false">"Orientação da tela"</string>
<string name="text_9e7294d264afecb7" formatted="false">"Expandir menu de controle remoto"</string>
<string name="text_9343612ee473edd6" formatted="false">"Conexão remota desconectada da notificação"</string>
<string name="text_9d32476fe83aef60" formatted="false">"Senha salva"</string>
<string name="text_1cbd391383283924" formatted="false">"A senha salva não pode ser lida, digite-a novamente."</string>
<string name="text_6792b9f341380c68" formatted="false">"A senha salva não pode ser mantida, digite-a novamente."</string>
<string name="text_74d3f0927d465047" formatted="false">"Ctrl+Alt+Del enviado"</string>
<string name="text_c28d86acbedba3b6" formatted="false">"O keep-alive em segundo plano está habilitado, a troca de aplicativos pode continuar a manter a conexão"</string>
<string name="text_506409ec08cb2a79" formatted="false">"Manutenção de atividade em segundo plano habilitada para retornar ou desconectar das notificações de conexão"</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"Concedido"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"Leia"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"Conectado "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"Conectado ao servidor"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"Taxa de quadros"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"Fluidez"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"Equilibrado"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"O arquivo de licença do aplicativo está ausente ou danificado. Reinstale o aplicativo."</string>
<string name="text_3f2904435942cc0f" formatted="false">"A versão de desenvolvimento contém modificações que ainda não foram lançadas. O link a seguir é o código fonte da versão básica."</string>
<string name="text_4338afaad52ea445" formatted="false">"Ativar salvamento"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"Habilitar tela de privacidade"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"Componentes de código aberto"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"Descrição dos direitos de software de código aberto"</string>
<string name="text_6e86570183c3cdd0" formatted="false">"Esta é atualmente a versão mais recente"</string>
<string name="text_24a52788cf474bf6" formatted="false">"Atualmente, a extremidade remota não suporta a entrada direta deste texto. Use uma versão do final controlado que suporte entrada de texto ou envie-o através da área de transferência."</string>
<string name="text_33f25b500697a7d9" formatted="false">"Após a conexão bem-sucedida, o dispositivo remoto mais próximo será exibido aqui."</string>
<string name="text_70341256d7317e7d" formatted="false">"Li e concordo com a Política de Privacidade"</string>
<string name="text_85c4c8fda0461f35" formatted="false">"Arquivo selecionado (não pode ser lido ou espaço insuficiente)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"O ID do dispositivo não foi encontrado"</string>
<string name="text_97b7b18df07b9787" formatted="false">"Falha no envio do comando, tente novamente"</string>
<string name="text_cb782027ca5986c7" formatted="false">"O comando foi enviado, aguardando confirmação do computador..."</string>
<string name="text_3ca40a586103f6b7" formatted="false">"Status de autorização"</string>
<string name="text_6e684586884ebee6" formatted="false">"Receber"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"receber "</string>
<string name="text_d16488b987723161" formatted="false">"Decodificador local"</string>
<string name="text_6b58198955259066" formatted="false">"Revogar"</string>
<string name="text_c52e42ee856825db" formatted="false">"Após a retirada, a conexão remota será interrompida, a visualização da tela será fechada e a visualização existente será apagada. O registro de conexão e a senha serão mantidos e será necessária autorização novamente antes de reconectar."</string>
<string name="text_61f4788032ec596b" formatted="false">"Retirar autorização de rede"</string>
<string name="text_e736e419008ac10a" formatted="false">"Retirar a autorização da rede?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"Recolher menu de controle remoto"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"Ocultar teclado"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"Ocultar mouse"</string>
<string name="text_5440f7424f2865bf" formatted="false">"Dados"</string>
<string name="text_94244f41d0a41378" formatted="false">"Falha ao salvar o arquivo, tente novamente"</string>
<string name="text_2553a785fef2a405" formatted="false">"Arquivo salvo"</string>
<string name="text_d573b3f9a810ea04" formatted="false">"A seleção do arquivo expirou e a sessão foi encerrada"</string>
<string name="text_985a5c380be49f4f" formatted="false">"O texto excede 128 KB"</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"Desconectar"</string>
<string name="text_09b1395f50589327" formatted="false">"Retornará à página inicial após desconectar"</string>
<string name="text_a73c0680db0d2e06" formatted="false">"Desconectar a conexão remota?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"Desconectar"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"Nova versão"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"Nova versão disponível: v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"Nova versão disponível: v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"Nenhum anúncio não lido"</string>
<string name="text_dca2c418ac967366" formatted="false">"Não é possível salvar o status de leitura localmente. Por favor, tente novamente."</string>
<string name="text_5c92da76360056cc" formatted="false">"Não é possível excluir este anúncio. Por favor, tente novamente."</string>
<string name="text_615709dd3129313c" formatted="false">"Não é possível iniciar a conexão remota"</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"Não é possível ler a licença de código aberto"</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"Tela"</string>
<string name="text_ffe21a671190323e" formatted="false">"Mostrar senha"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"Agora não"</string>
<string name="text_6d9a7758610a2432" formatted="false">"Sem anúncios"</string>
<string name="text_0b290c6600d92f43" formatted="false">"Conexões recentes"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"Servidor"</string>
<string name="text_dfe408e686175095" formatted="false">"Falha na verificação do certificado Server TLS"</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"Endereço do servidor"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"Falha no login do servidor, verifique o endereço do servidor e a rede"</string>
<string name="text_d270868960e68629" formatted="false">"Porta do servidor"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"A conexão do servidor expirou, verifique o endereço do servidor e a rede"</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"Não habilitado"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"Nenhum navegador encontrado para abrir o link"</string>
<string name="text_94bc3d40defef3e9" formatted="false">"Não autorizado"</string>
<string name="text_4d0680f9efaef147" formatted="false">"Não lido"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"Servidor não conectado · Clique para tentar novamente"</string>
<string name="text_7eea7966c0b746c5" formatted="false">"Este aplicativo usa OpenFEC para correção direta de erros noCeCILL-C1.0, que fornece garantias limitadas e limita a responsabilidade. Veja a licença completa para detalhes."</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"ID local "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"ID local {0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"Código-fonte e instruções de construção para esta versão"</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"A revisão fonte não está disponível para esta compilação."</string>
<string name="text_f0e274aa130e08a2" formatted="false">"Solte para atualizar"</string>
<string name="text_47077144715b7074" formatted="false">"Instruções de construção"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"Crie receitas e patches de projeto"</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"Ver código-fonte"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"Falha na verificação. Por favor, tente novamente mais tarde."</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"Verifique se há atualizações"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"Fechando a tela de privacidade…"</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"Estabelecendo conexão segura…"</string>
<string name="text_0da575e3c92de73a" formatted="false">"Ativando a tela de privacidade..."</string>
<string name="text_91834aebecabcbc3" formatted="false">"Verificando atualizações…"</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"Limpando..."</string>
<string name="text_0e6336c8fe918730" formatted="false">"Obtendo ID local..."</string>
<string name="text_faebd20c2801fd0f" formatted="false">"Lendo arquivo selecionado..."</string>
<string name="text_694186911ae3e4da" formatted="false">"Conectando"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"Conectando ao servidor de sinalização..."</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"Conectando ao servidor..."</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"Reconectando ao servidor..."</string>
<string name="text_4e4fce649fc00238" formatted="false">"Nenhum seletor de arquivos disponível para este dispositivo"</string>
<string name="text_bce2377283c2455a" formatted="false">"Limpar"</string>
<string name="text_ee0e76b8507212de" formatted="false">"Limpar prévias"</string>
<string name="text_ecc809e44637de18" formatted="false">"Limpar prévias?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"Código-fonte e instruções de construção"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"Versão do código-fonte"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"Versão"</string>
<string name="text_16410ee9663fad10" formatted="false">"Declaração de direitos autorais e licença"</string>
<string name="text_11de77e1e41e680f" formatted="false">"Teclado de computador"</string>
<string name="text_20f57900234debd7" formatted="false">"Qualidade"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"Preferência de vídeo"</string>
<string name="text_82a5600c470179fa" formatted="false">"Latência do vídeo"</string>
<string name="text_33b458faaf967e15" formatted="false">"Configurações de vídeo"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"Qualidade de vídeo"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"Taxa de quadros de captura de vídeo"</string>
<string name="text_66062971234f422d" formatted="false">"Touchpad"</string>
<string name="text_c421dc051bea3f11" formatted="false">"Rato relativo"</string>
<string name="text_de32e20193ad1042" formatted="false">"Entendi"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"OK"</string>
<string name="text_36f33adaf0942634" formatted="false">"Confirmar"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"Confirme a conexão"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"Off-line"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"Espaço"</string>
<string name="text_471a579e8d87faac" formatted="false">"O intervalo de portas é 1–65535"</string>
<string name="text_c7d0c77862e32b52" formatted="false">"Símbolos e teclas"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"Aguarde o computador retornar ao estado da tela de privacidade; computadores mais antigos podem não suportá-lo."</string>
<string name="text_5ee880e2524a983a" formatted="false">"Aguardando informações da tela remota"</string>
<string name="text_674c723a85ee7f57" formatted="false">"Aguardando a tela remota..."</string>
<string name="text_119f0483da4756c4" formatted="false">"Aguardando autorização de privacidade"</string>
<string name="text_7916071c1a8ee458" formatted="false">"Teclado do sistema"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"Toque direto"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"Rato Absoluto"</string>
<string name="text_872428a7210d5e0c" formatted="false">"Rede"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"Personalizado"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"Rato virtual"</string>
<string name="text_62c199f37bf95869" formatted="false">"A tela de privacidade remota foi desbloqueada usando o atalho local. Bloqueie-o novamente para restaurar a privacidade e desativar o desbloqueio de atalhos locais."</string>
<string name="text_a569ce729c1d6e19" formatted="false">"A versão controlada é muito baixa, atualize para a versão mais recente."</string>
<string name="text_d814476826827b78" formatted="false">"Codificador remoto"</string>
<string name="text_488402883e27f6f0" formatted="false">"Texto a ser enviado para o terminal remoto"</string>
<string name="text_c20f7618d330a854" formatted="false">"Vídeo"</string>
<string name="text_310c625425f014d8" formatted="false">"Licença"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"Licenças e avisos de direitos autorais"</string>
<string name="text_e24134b643a17975" formatted="false">"A licença e o aviso de direitos autorais estão disponíveis offline."</string>
<string name="text_5e7628f043ce71d0" formatted="false">"Falha ao salvar a identidade do dispositivo. Verifique o armazenamento seguro"</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"A identidade do dispositivo e as senhas de acesso salvas selecionadas são criptografadas usando o Android Keystore. Os registros de conexão são salvos apenas localmente e não são incluídos no backup do dispositivo. O conteúdo da área de transferência é apagado quando a sessão é encerrada. Mantenha a sessão com notificações picture-in-picture ou conectadas depois de sair do aplicativo e desconecte-se do aplicativo ou das notificações."</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"Configurações"</string>
<string name="text_4f8771952222a011" formatted="false">"Configurações, há uma nova versão"</string>
<string name="text_f4f712d5be91721a" formatted="false">"Senha de acesso"</string>
<string name="text_dd6c465847af1b60" formatted="false">"A senha de acesso está errada, digite-a novamente."</string>
<string name="text_defba1d2989bfab4" formatted="false">"Falha na verificação do certificado · Clique para tentar novamente"</string>
<string name="text_44f3d4d52347095a" formatted="false">"Por favor, preencha primeiro a autorização online"</string>
<string name="text_2a07ca002bc5093d" formatted="false">"Ative o serviço do sistema CrossDesk na extremidade remota e tente novamente"</string>
<string name="text_990fd23c62de5ccf" formatted="false">"A solicitação expirou e o status da tela de privacidade do computador não foi confirmado."</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"Aguarde o envio do arquivo atual."</string>
<string name="text_9305d9d72b06110d" formatted="false">"Insira uma porta no intervalo 1–65535"</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"Insira um ID de dispositivo de 9 dígitos"</string>
<string name="text_285a0714894d2899" formatted="false">"Por favor, insira uma senha de acesso válida"</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"Não foi possível aplicar as configurações. Tente novamente."</string>
<string name="text_a762e7e650e285fa" formatted="false">"Licença de software"</string>
<string name="text_572cf45ba43634b3" formatted="false">"Voltar"</string>
<string name="text_7565f94031582c7c" formatted="false">"Retornar à barra de controle"</string>
<string name="text_55113b6925c362fd" formatted="false">"Ainda não há registro de conexão"</string>
<string name="text_3aa527b2106cc987" formatted="false">"Este anúncio foi atualizado ou excluído. Volte à lista de anúncios para visualizá-lo."</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"A tarefa remota está fechada"</string>
<string name="text_c255841f68096d3b" formatted="false">"Área de Trabalho Remota"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"Área de toque da área de trabalho remota"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"Área de trabalho remota: toque e clique, arraste para mover, pressione e segure o botão direito, aperte para ampliar, amplie e arraste a tela com dois dedos, deslize para cima e para baixo com dois dedos para rolar na escala original"</string>
<string name="text_3535087994d050de" formatted="false">"Computador remoto"</string>
<string name="text_ed2e03072ce131de" formatted="false">"A janela remota foi fechada ou a manutenção de atividade em segundo plano falhou ao iniciar e a sessão foi encerrada."</string>
<string name="text_678b783fb578172b" formatted="false">"Conexão remota"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"Senha remota incorreta"</string>
<string name="text_a2ce499295e358ae" formatted="false">"A extremidade remota fechou a conexão"</string>
<string name="text_176e117df3ace734" formatted="false">"A extremidade remota está indisponível no momento"</string>
<string name="text_d2c0694243f93249" formatted="false">"A senha de acesso remoto expirou, reconecte."</string>
<string name="text_a5574109f0208e89" formatted="false">"Conectar"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"Conexão "</string>
<string name="text_89c4766afc85125f" formatted="false">"Falha na conexão"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"Falha na conexão · Clique para tentar novamente"</string>
<string name="text_2ab7d89557e779ab" formatted="false">"Conexão perdida"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"Conexão encerrada"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"Atraso de conexão (RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"Disponível quando conectado a um dispositivo remoto compatível."</string>
<string name="text_2123a9ed718f2c40" formatted="false">"Bloqueie a tela paisagem após a conexão ser estabelecida"</string>
<string name="text_f741e0536210eafa" formatted="false">"Solicitação de conexão"</string>
<string name="text_485a26050ce57431" formatted="false">"Método de conexão"</string>
<string name="text_5a048351a77fe88d" formatted="false">"Conecte-se ao servidor"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"Conecte-se ao servidor para obter os anúncios mais recentes."</string>
<string name="text_32cd27b5a72515f3" formatted="false">"A conexão expirou, verifique o ID do dispositivo, o servidor e o status on-line remoto"</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"Conecte-se à área de trabalho remota"</string>
<string name="text_d5407b4f3c666681" formatted="false">"Retrocesso"</string>
<string name="text_e02926784fcedb91" formatted="false">"Avisos"</string>
<string name="text_465a092680d132b3" formatted="false">"Não foi possível limpar algumas visualizações. Tente novamente."</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"Tente novamente"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"Tente salvar novamente o status de leitura"</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"Bloqueio"</string>
<string name="text_864200bbb241e067" formatted="false">"Teclado"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"Ler e autorizar"</string>
<string name="text_86651d17a401c55b" formatted="false">"Privacidade"</string>
<string name="text_26c6e011e343708c" formatted="false">"Privacidade e Autorização"</string>
<string name="text_6cad42fd3da85585" formatted="false">"Tela de privacidade"</string>
<string name="text_e157fafb76aad954" formatted="false">"Privacidade desativada"</string>
<string name="text_bbef0eac0c04f214" formatted="false">"Privacidade ativada"</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"Tela de privacidade desbloqueada"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"Anormalidade na tela de privacidade"</string>
<string name="text_598a2747dbd21dd5" formatted="false">"A tela de privacidade está temporariamente indisponível"</string>
<string name="text_4d59be3c75189032" formatted="false">"O status da tela de privacidade expirou, aguardando confirmação do computador"</string>
<string name="text_1674532c70ed1db2" formatted="false">"Status da tela de privacidade a ser confirmado"</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"Política de Privacidade"</string>
<string name="text_554e957e8e3c00af" formatted="false">"A política de privacidade não pode ser lida temporariamente. Recuse-se a se conectar à Internet e reinstale o aplicativo."</string>
<string name="text_7db2e189ecb01065" formatted="false">"Mudo"</string>
<string name="text_296c632ec857a0ba" formatted="false">"Áudio"</string>
<string name="text_0cadb49096a382e0" formatted="false">"Site do projeto"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"As visualizações podem conter informações pessoais ou de trabalho do computador remoto. Após ligá-lo, cada conexão salvará automaticamente uma imagem na máquina e a exibirá nas conexões recentes. Você pode fechar e limpar a qualquer momento."</string>
<string name="text_b1c27820fec23edb" formatted="false">"Alta"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"Padrão"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"Desligado por padrão. Quando ligado, a tela entrará e permanecerá na orientação paisagem quando o telefone estiver conectado; quando desligado, seguirá a direção do dispositivo. Tablets e janelas múltiplas não são afetados."</string>
<string name="text_1184a97edd8e1646" formatted="false">"Botão direito do mouse, pressione e arraste, solte para finalizar o arrasto"</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"Botão esquerdo do mouse, pressione e arraste, solte para finalizar o arrasto"</string>
<string name="text_269f39a07b4a4e01" formatted="false">"Controle do mouse"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"Roda do mouse, deslize para cima e para baixo para sair do intervalo de teclas"</string>
<string name="text_9aa238fc6121dede" formatted="false">", taxa de perda de pacotes "</string>
<string name="text_0b5d16c21095fe10" formatted="false">", enviar "</string>
<string name="text_ba9a792d063a8c86" formatted="false">", senha salva"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", receba "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", clique em Tentar novamente"</string>
</resources>
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" · Завершено"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" Передача не удалась"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" устройства"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" Статья · "</string>
<string name="text_2c894057fc526576" formatted="false">" непрочитанные элементы"</string>
<string name="text_d0ac753c6681e578" formatted="false">"Удаленное подключение CrossDesk"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL версия 3"</string>
<string name="text_5ae7dc4079223967" formatted="false">"Уведомление OpenFEC"</string>
<string name="text_69ad29820a259526" formatted="false">"Лицензия и исходный код OpenFEC"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"Прямое соединение P2P"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRTP включен"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"Реле ПОВОРОТА"</string>
<string name="text_ebede975edd52aee" formatted="false">"Передача {0} не удалась."</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"Устройства: {0}"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"{0} непрочитанные элементы"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0}, прием {1}, отправка {2}, скорость потери пакетов {3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● Подключено"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"Потяните вниз, чтобы обновить"</string>
<string name="text_37772187ab854191" formatted="false">"Потери пакетов"</string>
<string name="text_a567bdaa11367f26" formatted="false">"Среднее"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"Отдает приоритет сохранению плавности изображения и более агрессивно снижает разрешение при высокой пропускной способности или нагрузке на кодирование."</string>
<string name="text_aa9e366f68d3d097" formatted="false">"Низкое"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"Использование пользовательского сервера"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"Сохранить пароль"</string>
<string name="text_10019cef593819ed" formatted="false">"Сохранить полученный файл"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"Сохранять превью удалённого экрана"</string>
<string name="text_0f197074373e38fd" formatted="false">"Сохранить предварительный просмотр удаленного экрана?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"Сигнальное соединение прервано, пожалуйста, переподключитесь."</string>
<string name="text_789f22a8b1b34797" formatted="false">"Не удалось загрузить объявление. Повторите попытку."</string>
<string name="text_00554b17c432937e" formatted="false">"Подробности объявления"</string>
<string name="text_2428420e407fe2e8" formatted="false">"Итого "</string>
<string name="text_d977c7568cc5201e" formatted="false">"Всего элементов {0}· Непрочитанные элементы {1}"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"О программе"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"О CrossDesk"</string>
<string name="text_390a95b65eb33052" formatted="false">"О, вышла новая версия"</string>
<string name="text_3fd47edce45b3603" formatted="false">"Закрыть"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"Закрыть панель управления"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"Выключить приватный экран"</string>
<string name="text_7d466b69be42fc43" formatted="false">"Балансирует четкость и плавность изображения, постепенно снижая разрешение под постоянным давлением."</string>
<string name="text_b92421822c213c2b" formatted="false">"Разрешение"</string>
<string name="text_2f9daa828907b93f" formatted="false">"Удалить"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"Удалить объявление"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"Удалить запись"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"Удалить это объявление?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"Обновить объявление"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"Буфер обмена"</string>
<string name="text_4927a53bcc886afb" formatted="false">"Загрузка…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"Загрузить еще"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"Рекомендуется обновление"</string>
<string name="text_861521c9e107c648" formatted="false">"Не удалось инициализировать собственное соединение."</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"Отправка"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"Отправить "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"После отправки нажмите «Вставить» на удаленной клавиатуре. Полученный удаленный текст можно вручную скопировать на локальный компьютер."</string>
<string name="text_e7281fbca7af369c" formatted="false">"Отправить файл"</string>
<string name="text_ce6c02d19022e016" formatted="false">"Отправить текст"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"Отмена"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"Отмена соединения"</string>
<string name="text_72814b15a6fa152c" formatted="false">"На каждый компонент распространяется действие указанной лицензии с открытым исходным кодом, а предоставленные права не подпадают под действие общих условий использования этого приложения. Для просмотра исходного кода требуется подключение к Интернету."</string>
<string name="text_e009c148d1890dac" formatted="false">"Итого"</string>
<string name="text_81c92013219dd115" formatted="false">"Согласитесь и продолжайте."</string>
<string name="text_409696774c9998ce" formatted="false">"Оставайтесь на связи в фоновом режиме, нажмите, чтобы вернуться к удаленному рабочему столу."</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"Не удалось запустить фоновую поддержку активности, оставьте приложение на переднем плане."</string>
<string name="text_d96a21463557171d" formatted="false">"Не удалось запустить фоновую поддержку активности. Оставьте приложение в активном режиме и переподключитесь."</string>
<string name="text_952f7300de0a7bd9" formatted="false">"Фоновое соединение прервано, подключитесь заново."</string>
<string name="text_da904f715e1cff87" formatted="false">"Служба фонового подключения остановлена, подключитесь повторно."</string>
<string name="text_0812359e18082071" formatted="false">"Новые превью по-прежнему будут сохраняться при последующих подключениях."</string>
<string name="text_b9086662b1df1f20" formatted="false">"В сети"</string>
<string name="text_39f2f444a042643e" formatted="false">"Введите имя хоста или IPv4-адрес без протокола и пути."</string>
<string name="text_473400815c0f687d" formatted="false">"Звук"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"Копирование удаленного текста"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"Всегда поддерживайте более высокое разрешение и уменьшайте частоту кадров, чтобы обеспечить четкость изображения при недостаточной полосе пропускания."</string>
<string name="text_7ff73de85ec16d15" formatted="false">"Шифрование мультимедиа"</string>
<string name="text_22dc6217af77bc37" formatted="false">"Буквы и цифры"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"Не удалось инициализировать безопасное хранилище или сертификат. Повторите попытку или очистите данные приложения."</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"Готово"</string>
<string name="text_678328f6f02881c7" formatted="false">"Вы можете проверить наличие обновлений после завершения авторизации конфиденциальности."</string>
<string name="text_c4361f9abdba4882" formatted="false">"Зарегистрируйте локальную личность после завершения авторизации конфиденциальности."</string>
<string name="text_c29b888e357d727d" formatted="false">"Не удалось сохранить пароль, это соединение все еще можно использовать."</string>
<string name="text_3f31300a23afb390" formatted="false">"Пароли сохраняются в зашифрованном виде в собственном хранилище ключей Android."</string>
<string name="text_8d3b052c4c67acda" formatted="false">"Удаленный ID"</string>
<string name="text_0512084e130984bc" formatted="false">"Идентификатор узла "</string>
<string name="text_c5147d65960196b0" formatted="false">"Идентификатор узла {0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"Все удаленные просмотры, сохраненные на этом компьютере, будут удалены, а записи подключений и пароли сохранятся."</string>
<string name="text_60cc99cc0013972e" formatted="false">"Ориентация экрана"</string>
<string name="text_9e7294d264afecb7" formatted="false">"Развернуть меню пульта дистанционного управления"</string>
<string name="text_9343612ee473edd6" formatted="false">"Удаленное соединение отключено из-за уведомления"</string>
<string name="text_9d32476fe83aef60" formatted="false">"Пароль сохранён"</string>
<string name="text_1cbd391383283924" formatted="false">"Сохраненный пароль невозможно прочитать, введите его еще раз."</string>
<string name="text_6792b9f341380c68" formatted="false">"Сохраненный пароль невозможно сохранить, введите его еще раз."</string>
<string name="text_74d3f0927d465047" formatted="false">"Ctrl+Alt+Del отправлено"</string>
<string name="text_c28d86acbedba3b6" formatted="false">"Фоновая поддержка активности включена, переключающиеся приложения могут продолжать поддерживать соединение."</string>
<string name="text_506409ec08cb2a79" formatted="false">"Фоновая поддержка активности включена для возврата или отключения от уведомлений о соединении."</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"Разрешено"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"Прочитано"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"Подключено "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"Подключено к серверу"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"Частота кадров"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"Плавность"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"Баланс"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"Файл лицензии в приложении отсутствует или поврежден, переустановите приложение."</string>
<string name="text_3f2904435942cc0f" formatted="false">"Версия для разработки содержит модификации, которые еще не были выпущены. По следующей ссылке приведен исходный код базовой версии."</string>
<string name="text_4338afaad52ea445" formatted="false">"Включить сохранение"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"Включить приватный экран"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"Компоненты с открытым кодом"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"Описание прав на программное обеспечение с открытым исходным кодом."</string>
<string name="text_6e86570183c3cdd0" formatted="false">"На данный момент это последняя версия."</string>
<string name="text_24a52788cf474bf6" formatted="false">"В настоящее время удаленный конец не поддерживает прямой ввод этого текста. Пожалуйста, используйте версию управляемого конца, поддерживающую ввод текста, или отправьте его через буфер обмена."</string>
<string name="text_33f25b500697a7d9" formatted="false">"При успешном подключении здесь будет отображено ближайшее удаленное устройство."</string>
<string name="text_70341256d7317e7d" formatted="false">"Я прочитал и согласен с Политикой конфиденциальности."</string>
<string name="text_85c4c8fda0461f35" formatted="false">"Выбранный файл (невозможно прочитать или недостаточно места)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"Идентификатор устройства не найден."</string>
<string name="text_97b7b18df07b9787" formatted="false">"Не удалось отправить команду. Повторите попытку."</string>
<string name="text_cb782027ca5986c7" formatted="false">"Команда отправлена, ждём подтверждения от компьютера..."</string>
<string name="text_3ca40a586103f6b7" formatted="false">"Статус авторизации"</string>
<string name="text_6e684586884ebee6" formatted="false">"Приём"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"получить "</string>
<string name="text_d16488b987723161" formatted="false">"Локальный декодер"</string>
<string name="text_6b58198955259066" formatted="false">"Отозвать"</string>
<string name="text_c52e42ee856825db" formatted="false">"После выхода удаленное соединение будет остановлено, предварительный просмотр экрана закроется, а существующий предварительный просмотр будет очищен. Запись о подключении и пароль будут сохранены, и перед повторным подключением потребуется повторная авторизация."</string>
<string name="text_61f4788032ec596b" formatted="false">"Отозвать сетевую авторизацию"</string>
<string name="text_e736e419008ac10a" formatted="false">"Отозвать сетевую авторизацию?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"Свернуть меню пульта дистанционного управления"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"Скрыть клавиатуру"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"Скрыть мышь"</string>
<string name="text_5440f7424f2865bf" formatted="false">"Данные"</string>
<string name="text_94244f41d0a41378" formatted="false">"Не удалось сохранить файл. Повторите попытку."</string>
<string name="text_2553a785fef2a405" formatted="false">"Файл сохранен."</string>
<string name="text_d573b3f9a810ea04" formatted="false">"Тайм-аут выбора файла истек, сеанс завершен."</string>
<string name="text_985a5c380be49f4f" formatted="false">"Размер текста превышает 128 КБ."</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"Отключить"</string>
<string name="text_09b1395f50589327" formatted="false">"Вернется на домашнюю страницу после отключения."</string>
<string name="text_a73c0680db0d2e06" formatted="false">"Отключить удаленное соединение?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"Отключить"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"Новая версия"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"Доступна новая версия: v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"Доступна новая версия: v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"Нет непрочитанных объявлений"</string>
<string name="text_dca2c418ac967366" formatted="false">"Не удалось сохранить статус чтения локально. Повторите попытку."</string>
<string name="text_5c92da76360056cc" formatted="false">"Не удалось удалить объявление. Повторите попытку."</string>
<string name="text_615709dd3129313c" formatted="false">"Невозможно инициировать удаленное соединение."</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"Невозможно прочитать лицензию с открытым исходным кодом."</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"Экран"</string>
<string name="text_ffe21a671190323e" formatted="false">"Показать пароль"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"Не сейчас"</string>
<string name="text_6d9a7758610a2432" formatted="false">"Нет объявлений"</string>
<string name="text_0b290c6600d92f43" formatted="false">"Недавние связи"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"Сервер"</string>
<string name="text_dfe408e686175095" formatted="false">"Проверка Server TLS certificate не удалась."</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"Адрес сервера"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"Не удалось войти на сервер, проверьте адрес сервера и сеть."</string>
<string name="text_d270868960e68629" formatted="false">"Порт сервера"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"Тайм-аут подключения к серверу истек, проверьте адрес сервера и сеть."</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"Не включено"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"Не найден браузер, в котором можно было бы открыть ссылку."</string>
<string name="text_94bc3d40defef3e9" formatted="false">"Несанкционированное"</string>
<string name="text_4d0680f9efaef147" formatted="false">"Не прочитано"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"Сервер не подключен · Нажмите, чтобы повторить попытку."</string>
<string name="text_7eea7966c0b746c5" formatted="false">"Приложение использует OpenFEC для прямого исправления ошибок по лицензии CeCILL-C 1.0 с ограниченной гарантией и ответственностью. Подробности — в тексте лицензии."</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"Местный идентификатор "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"Локальный идентификатор {0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"Исходный код и инструкции по сборке этой версии."</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"Ревизия исходного кода этой сборки недоступна."</string>
<string name="text_f0e274aa130e08a2" formatted="false">"Отпустите, чтобы обновить"</string>
<string name="text_47077144715b7074" formatted="false">"Инструкции по сборке"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"Рецепты сборки и исправления проектов."</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"Открыть исходный код"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"Не удалось проверить обновления. Повторите позже."</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"Проверить обновления"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"Закрытие экрана конфиденциальности…"</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"Установление безопасного соединения…"</string>
<string name="text_0da575e3c92de73a" formatted="false">"Включение экрана конфиденциальности..."</string>
<string name="text_91834aebecabcbc3" formatted="false">"Проверка обновлений…"</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"Очистка..."</string>
<string name="text_0e6336c8fe918730" formatted="false">"Получение локального идентификатора..."</string>
<string name="text_faebd20c2801fd0f" formatted="false">"Чтение выбранного файла..."</string>
<string name="text_694186911ae3e4da" formatted="false">"Подключение"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"Подключение к серверу сигнализации..."</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"Подключение к серверу..."</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"Повторное подключение к серверу..."</string>
<string name="text_4e4fce649fc00238" formatted="false">"Для этого устройства средство выбора файлов недоступно."</string>
<string name="text_bce2377283c2455a" formatted="false">"Очистить"</string>
<string name="text_ee0e76b8507212de" formatted="false">"Удалить превью"</string>
<string name="text_ecc809e44637de18" formatted="false">"Удалить превью?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"Исходный код и сборка"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"Версия исходного кода"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"Версия"</string>
<string name="text_16410ee9663fad10" formatted="false">"Положение об авторских правах и лицензии."</string>
<string name="text_11de77e1e41e680f" formatted="false">"Компьютерная клавиатура"</string>
<string name="text_20f57900234debd7" formatted="false">"Качество"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"Режим видео"</string>
<string name="text_82a5600c470179fa" formatted="false">"Задержка видео"</string>
<string name="text_33b458faaf967e15" formatted="false">"Настройки видео"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"Качество видео"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"Частота захвата видео"</string>
<string name="text_66062971234f422d" formatted="false">"Тачпад"</string>
<string name="text_c421dc051bea3f11" formatted="false">"Родственная мышь"</string>
<string name="text_de32e20193ad1042" formatted="false">"Понятно"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"ОК"</string>
<string name="text_36f33adaf0942634" formatted="false">"Подтвердить"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"Подтвердите соединение"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"Не в сети"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"Пробел"</string>
<string name="text_471a579e8d87faac" formatted="false">"Диапазон портов: 1–65535."</string>
<string name="text_c7d0c77862e32b52" formatted="false">"Символы и клавиши"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"Подождите, пока компьютер вернется в состояние экрана конфиденциальности; старые компьютеры могут его не поддерживать."</string>
<string name="text_5ee880e2524a983a" formatted="false">"Ожидание информации об удаленном экране."</string>
<string name="text_674c723a85ee7f57" formatted="false">"Ожидание удаленного экрана..."</string>
<string name="text_119f0483da4756c4" formatted="false">"Ожидание разрешения на конфиденциальность."</string>
<string name="text_7916071c1a8ee458" formatted="false">"Системная клавиатура"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"Касание"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"Абсолютная мышь"</string>
<string name="text_872428a7210d5e0c" formatted="false">"Сеть"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"Пользовательский"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"Виртуальная мышь"</string>
<string name="text_62c199f37bf95869" formatted="false">"Приватный экран отключён локальным сочетанием клавиш. Заблокируйте его снова, чтобы восстановить защиту и запретить локальную разблокировку."</string>
<string name="text_a569ce729c1d6e19" formatted="false">"Управляемая версия слишком низкая, обновите ее до последней версии."</string>
<string name="text_d814476826827b78" formatted="false">"Кодер удалённой стороны"</string>
<string name="text_488402883e27f6f0" formatted="false">"Текст для отправки на удаленный конец"</string>
<string name="text_c20f7618d330a854" formatted="false">"Видео"</string>
<string name="text_310c625425f014d8" formatted="false">"Лицензия"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"Лицензии и авторские права"</string>
<string name="text_e24134b643a17975" formatted="false">"Лицензия и уведомление об авторских правах доступны в автономном режиме."</string>
<string name="text_5e7628f043ce71d0" formatted="false">"Не удалось сохранить идентификационные данные устройства. Проверьте безопасное хранилище."</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"Идентификатор устройства и выбранные сохраненные пароли доступа шифруются с помощью хранилища ключей Android. Записи подключений сохраняются только локально и не включаются в резервную копию устройства. Содержимое буфера обмена очищается после завершения сеанса. Оставайтесь в сеансе с помощью «картинка в картинке» или подключенных уведомлений после выхода из приложения и отключайтесь внутри приложения или уведомлений."</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"Настройки"</string>
<string name="text_4f8771952222a011" formatted="false">"Настройки, есть новая версия"</string>
<string name="text_f4f712d5be91721a" formatted="false">"Пароль доступа"</string>
<string name="text_dd6c465847af1b60" formatted="false">"Неверный пароль доступа, пожалуйста, введите его еще раз."</string>
<string name="text_defba1d2989bfab4" formatted="false">"Не удалось проверить сертификат · Нажмите, чтобы повторить попытку."</string>
<string name="text_44f3d4d52347095a" formatted="false">"Сначала пройдите онлайн-авторизацию."</string>
<string name="text_2a07ca002bc5093d" formatted="false">"Пожалуйста, включите системную службу CrossDesk на удаленном конце и повторите попытку."</string>
<string name="text_990fd23c62de5ccf" formatted="false">"Время ожидания запроса истекло, а состояние экрана конфиденциальности компьютера не подтверждено."</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"Пожалуйста, дождитесь отправки текущего файла."</string>
<string name="text_9305d9d72b06110d" formatted="false">"Введите порт в диапазоне 1–65535."</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"Введите ID из 9 цифр"</string>
<string name="text_285a0714894d2899" formatted="false">"Пожалуйста, введите действительный пароль доступа."</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"Не удалось применить настройки. Повторите попытку."</string>
<string name="text_a762e7e650e285fa" formatted="false">"Лицензия программы"</string>
<string name="text_572cf45ba43634b3" formatted="false">"Назад"</string>
<string name="text_7565f94031582c7c" formatted="false">"Возврат к панели управления."</string>
<string name="text_55113b6925c362fd" formatted="false">"Пока нет записей о подключении"</string>
<string name="text_3aa527b2106cc987" formatted="false">"Это объявление было обновлено или удалено. Чтобы просмотреть его, вернитесь к списку объявлений."</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"Удаленная задача закрыта."</string>
<string name="text_c255841f68096d3b" formatted="false">"Удаленный рабочий стол"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"Сенсорная область удаленного рабочего стола"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"Удаленный рабочий стол: коснитесь и щелкните, перетащите для перемещения, нажмите и удерживайте правую кнопку, сведите пальцы для масштабирования, увеличьте масштаб и перетащите экран двумя пальцами, проведите двумя пальцами вверх и вниз для прокрутки в исходном масштабе."</string>
<string name="text_3535087994d050de" formatted="false">"Удаленный компьютер"</string>
<string name="text_ed2e03072ce131de" formatted="false">"Удаленное окно было закрыто, или не удалось запустить фоновую проверку активности, и сеанс завершился."</string>
<string name="text_678b783fb578172b" formatted="false">"Удаленное подключение"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"Неверный удаленный пароль."</string>
<string name="text_a2ce499295e358ae" formatted="false">"Удаленная сторона закрыла соединение."</string>
<string name="text_176e117df3ace734" formatted="false">"Удаленный конец в настоящее время недоступен."</string>
<string name="text_d2c0694243f93249" formatted="false">"Срок действия пароля удаленного доступа истек, подключитесь повторно."</string>
<string name="text_a5574109f0208e89" formatted="false">"Подключиться"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"Подключение "</string>
<string name="text_89c4766afc85125f" formatted="false">"Соединение не удалось"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"Не удалось подключиться · Нажмите, чтобы повторить попытку."</string>
<string name="text_2ab7d89557e779ab" formatted="false">"Соединение потеряно"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"Соединение завершено"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"Задержка соединения (RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"Доступно при подключении к поддерживаемому устройству."</string>
<string name="text_2123a9ed718f2c40" formatted="false">"Блокировка горизонтального экрана после установления соединения."</string>
<string name="text_f741e0536210eafa" formatted="false">"Запрос на подключение"</string>
<string name="text_485a26050ce57431" formatted="false">"Способ подключения"</string>
<string name="text_5a048351a77fe88d" formatted="false">"Подключиться к серверу"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"Подключитесь к серверу, чтобы получать последние объявления."</string>
<string name="text_32cd27b5a72515f3" formatted="false">"Тайм-аут соединения истек, проверьте идентификатор устройства, сервер и удаленный онлайн-статус."</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"Подключение к удаленному рабочему столу"</string>
<string name="text_d5407b4f3c666681" formatted="false">"Возврат"</string>
<string name="text_e02926784fcedb91" formatted="false">"Объявления"</string>
<string name="text_465a092680d132b3" formatted="false">"Некоторые предварительные просмотры не удалось удалить. Повторите попытку."</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"Попробуйте еще раз"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"Повторить попытку сохранения статуса чтения."</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"Заблокировать"</string>
<string name="text_864200bbb241e067" formatted="false">"Клавиатура"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"Прочитать и авторизоваться"</string>
<string name="text_86651d17a401c55b" formatted="false">"Конфиденциальность"</string>
<string name="text_26c6e011e343708c" formatted="false">"Конфиденциальность и авторизация"</string>
<string name="text_6cad42fd3da85585" formatted="false">"Приватный экран"</string>
<string name="text_e157fafb76aad954" formatted="false">"Приватный экран выкл."</string>
<string name="text_bbef0eac0c04f214" formatted="false">"Приватный экран вкл."</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"Приватный экран разблокирован"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"Неисправность экрана конфиденциальности."</string>
<string name="text_598a2747dbd21dd5" formatted="false">"Экран конфиденциальности временно недоступен."</string>
<string name="text_4d59be3c75189032" formatted="false">"Срок действия статуса экрана конфиденциальности истек, ожидание подтверждения от компьютера."</string>
<string name="text_1674532c70ed1db2" formatted="false">"Статус экрана конфиденциальности подлежит подтверждению."</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"Политика конфиденциальности"</string>
<string name="text_554e957e8e3c00af" formatted="false">"Политику конфиденциальности временно невозможно прочитать. Пожалуйста, откажитесь от подключения к Интернету и переустановите приложение."</string>
<string name="text_7db2e189ecb01065" formatted="false">"Без звука"</string>
<string name="text_296c632ec857a0ba" formatted="false">"Аудио"</string>
<string name="text_0cadb49096a382e0" formatted="false">"Сайт проекта"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"Предварительный просмотр может содержать личную или рабочую информацию с удаленного компьютера. После его включения каждое подключение будет автоматически сохранять изображение на устройстве и отображать его при последних подключениях. Вы можете закрыть и очистить в любое время."</string>
<string name="text_b1c27820fec23edb" formatted="false">"Высокое"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"По умолчанию"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"По умолчанию выключено. При включении экран войдет и останется в альбомной ориентации при подключении телефона; в выключенном состоянии он будет следовать направлению устройства. Планшеты и мультиокна не затронуты."</string>
<string name="text_1184a97edd8e1646" formatted="false">"Правая кнопка мыши, нажмите и перетащите, отпустите, чтобы завершить перетаскивание."</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"Левую кнопку мыши нажмите и перетащите, отпустите, чтобы завершить перетаскивание."</string>
<string name="text_269f39a07b4a4e01" formatted="false">"Управление мышью"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"Колесо мыши: проведите вверх и вниз, чтобы выйти за пределы диапазона клавиш."</string>
<string name="text_9aa238fc6121dede" formatted="false">", коэффициент потери пакетов "</string>
<string name="text_0b5d16c21095fe10" formatted="false">", отправить "</string>
<string name="text_ba9a792d063a8c86" formatted="false">", пароль сохранен"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", получить "</string>
<string name="text_42237b5ec70c26f3" formatted="false">"нажмите «Повторить»."</string>
</resources>
@@ -0,0 +1,308 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by localization/generate.py. -->
<resources>
<string name="text_58b6df1059088699" formatted="false">" · Completed"</string>
<string name="text_53af552fb3af7f6e" formatted="false">" Transfer failed"</string>
<string name="text_23ebae95d26dfe5e" formatted="false">" devices"</string>
<string name="text_a7f4dfd234a43b16" formatted="false">" Article · "</string>
<string name="text_2c894057fc526576" formatted="false">" unread items"</string>
<string name="text_d0ac753c6681e578" formatted="false">"CrossDesk remote connection"</string>
<string name="text_0991c6fc1de4ffd6" formatted="false">"GNU GPL version 3"</string>
<string name="text_5ae7dc4079223967" formatted="false">"OpenFEC notice"</string>
<string name="text_69ad29820a259526" formatted="false">"OpenFEC license and source"</string>
<string name="text_63ae4a0c7a448d21" formatted="false">"P2P direct connection"</string>
<string name="text_70cae3c6aad8e152" formatted="false">"SRTP enabled"</string>
<string name="text_ef813fec7d6d8a3b" formatted="false">"TURN relay"</string>
<string name="text_ebede975edd52aee" formatted="false">"{0} transmission failed"</string>
<string name="text_b3ffb6e982ecf5df" formatted="false">"Devices: {0}"</string>
<string name="text_fc66a78b9e27fecf" formatted="false">"{0} unread items"</string>
<string name="text_dcb59cdcf66150a1" formatted="false">"{0}, receiving {1}, sending {2}, packet loss rate {3}"</string>
<string name="text_01965b3249458d01" formatted="false">"● Connected"</string>
<string name="text_eb4236fe6a926a5a" formatted="false">"Pull down to refresh"</string>
<string name="text_37772187ab854191" formatted="false">"Loss Rate"</string>
<string name="text_a567bdaa11367f26" formatted="false">"Medium"</string>
<string name="text_e6ff95a9fe6acc7f" formatted="false">"Prioritizes keeping the picture smooth, and will reduce resolution more aggressively when bandwidth or encoding pressure is high."</string>
<string name="text_aa9e366f68d3d097" formatted="false">"Low"</string>
<string name="text_f03d03eb35e3330c" formatted="false">"Using a custom server"</string>
<string name="text_2e9f4819e064ce12" formatted="false">"Save password"</string>
<string name="text_10019cef593819ed" formatted="false">"Save received file"</string>
<string name="text_3bcdcd330bb3044c" formatted="false">"Save remote screen previews"</string>
<string name="text_0f197074373e38fd" formatted="false">"Save remote screen preview?"</string>
<string name="text_71ca53f95a8484da" formatted="false">"The signaling connection is interrupted, please reconnect."</string>
<string name="text_789f22a8b1b34797" formatted="false">"Announcement loading failed, please try again."</string>
<string name="text_00554b17c432937e" formatted="false">"Announcement details"</string>
<string name="text_2428420e407fe2e8" formatted="false">"Total "</string>
<string name="text_d977c7568cc5201e" formatted="false">"Total {0} items ·{1} unread items"</string>
<string name="text_52d25a9e30ba94f1" formatted="false">"About"</string>
<string name="text_459c1e980c1c8ce2" formatted="false">"About CrossDesk"</string>
<string name="text_390a95b65eb33052" formatted="false">"About, there is a new version"</string>
<string name="text_3fd47edce45b3603" formatted="false">"Close"</string>
<string name="text_56a18c93e12fdc4e" formatted="false">"Close control bar"</string>
<string name="text_4eb0565cc4658d7f" formatted="false">"Disable privacy screen"</string>
<string name="text_7d466b69be42fc43" formatted="false">"Balances clarity and smoothness, only gradually reducing resolution under constant pressure."</string>
<string name="text_b92421822c213c2b" formatted="false">"Resolution"</string>
<string name="text_2f9daa828907b93f" formatted="false">"Delete"</string>
<string name="text_0e46cdb3442edfba" formatted="false">"Delete announcement"</string>
<string name="text_f2cf9101bb2d8816" formatted="false">"Delete record"</string>
<string name="text_d087671ca7df7f0f" formatted="false">"Delete this announcement?"</string>
<string name="text_33e7584cd5c252d7" formatted="false">"Refresh announcement"</string>
<string name="text_8c10e2c1728ddf8e" formatted="false">"Clipboard"</string>
<string name="text_4927a53bcc886afb" formatted="false">"Loading…"</string>
<string name="text_af90a08fec8ee28d" formatted="false">"Load more"</string>
<string name="text_a739f24a9fd8a5d9" formatted="false">"Update recommended"</string>
<string name="text_861521c9e107c648" formatted="false">"Native connection initialization failed"</string>
<string name="text_edecf0ae6e5144f9" formatted="false">"Send"</string>
<string name="text_43b7b60867ca51e7" formatted="false">"Send "</string>
<string name="text_da7df5a5543c4ba1" formatted="false">"After sending, click \"Paste\" on the remote keyboard. The received remote text can be manually copied to the local machine."</string>
<string name="text_e7281fbca7af369c" formatted="false">"Send file"</string>
<string name="text_ce6c02d19022e016" formatted="false">"Send text"</string>
<string name="text_2cd0f3be8738a86c" formatted="false">"Cancel"</string>
<string name="text_3687f953cfe85fb8" formatted="false">"Cancel connection"</string>
<string name="text_72814b15a6fa152c" formatted="false">"Each component is subject to the listed open source license, and the rights granted are not subject to the general terms of use of this application. Viewing source code requires an internet connection."</string>
<string name="text_e009c148d1890dac" formatted="false">"Total"</string>
<string name="text_81c92013219dd115" formatted="false">"Agree and continue"</string>
<string name="text_409696774c9998ce" formatted="false">"Keep connected in the background, click to return to the remote desktop"</string>
<string name="text_dcb8d4e2e6973912" formatted="false">"Background keepalive failed to start, please keep the application in the foreground"</string>
<string name="text_d96a21463557171d" formatted="false">"Background keepalive failed to start, please keep the application in the foreground and reconnect."</string>
<string name="text_952f7300de0a7bd9" formatted="false">"The background connection has ended, please reconnect."</string>
<string name="text_da904f715e1cff87" formatted="false">"The background connection service has stopped, please reconnect."</string>
<string name="text_0812359e18082071" formatted="false">"New previews will still be saved on subsequent connections."</string>
<string name="text_b9086662b1df1f20" formatted="false">"Online"</string>
<string name="text_39f2f444a042643e" formatted="false">"Fill in the host name or IPv4 address, without protocol and path"</string>
<string name="text_473400815c0f687d" formatted="false">"Sound"</string>
<string name="text_ab5ca1fab212f4ca" formatted="false">"Copy remote text"</string>
<string name="text_c92269b2039e7aa3" formatted="false">"Always maintain a higher resolution and reduce the frame rate to ensure picture clarity when bandwidth is insufficient."</string>
<string name="text_7ff73de85ec16d15" formatted="false">"Media encryption"</string>
<string name="text_22dc6217af77bc37" formatted="false">"Letters &amp; numbers"</string>
<string name="text_9ca1cdaf831182e3" formatted="false">"Secure storage or certificate initialization failed, please try again or clear application data"</string>
<string name="text_c0b3fbff51ccc40b" formatted="false">"Done"</string>
<string name="text_678328f6f02881c7" formatted="false">"You can check for updates after completing privacy authorization"</string>
<string name="text_c4361f9abdba4882" formatted="false">"Register the local identity after completing privacy authorization"</string>
<string name="text_c29b888e357d727d" formatted="false">"Password saving failed, this connection can still be used"</string>
<string name="text_3f31300a23afb390" formatted="false">"Passwords are saved encrypted in the native Android Keystore"</string>
<string name="text_8d3b052c4c67acda" formatted="false">"Remote ID"</string>
<string name="text_0512084e130984bc" formatted="false">"Peer ID "</string>
<string name="text_c5147d65960196b0" formatted="false">"Peer ID {0}"</string>
<string name="text_882b705d31ac7e2a" formatted="false">"All remote previews saved on this machine will be deleted, and the connection records and passwords will be retained."</string>
<string name="text_60cc99cc0013972e" formatted="false">"Screen orientation"</string>
<string name="text_9e7294d264afecb7" formatted="false">"Expand remote control menu"</string>
<string name="text_9343612ee473edd6" formatted="false">"Remote connection disconnected from notification"</string>
<string name="text_9d32476fe83aef60" formatted="false">"Password saved"</string>
<string name="text_1cbd391383283924" formatted="false">"The saved password cannot be read, please re-enter it."</string>
<string name="text_6792b9f341380c68" formatted="false">"The saved password cannot be retained, please re-enter it."</string>
<string name="text_74d3f0927d465047" formatted="false">"Ctrl+Alt+Del sent"</string>
<string name="text_c28d86acbedba3b6" formatted="false">"Background keep-alive is enabled, switching applications can continue to maintain the connection"</string>
<string name="text_506409ec08cb2a79" formatted="false">"Background keepalive enabled to return or disconnect from connection notifications"</string>
<string name="text_521e65ffc7d0ff2d" formatted="false">"Granted"</string>
<string name="text_efdd1a47e4beb2f9" formatted="false">"Read"</string>
<string name="text_1f672b9b18d18e87" formatted="false">"Connected "</string>
<string name="text_9c05d9d7fa1b0abd" formatted="false">"Connected to server"</string>
<string name="text_f9404bf335bc14cf" formatted="false">"Frame rate"</string>
<string name="text_9ae0c69e26db1d6a" formatted="false">"Smooth"</string>
<string name="text_df544e46aa4d5c17" formatted="false">"Balanced"</string>
<string name="text_3362e8fa7f65cc2f" formatted="false">"The license file in the application is missing or damaged, please reinstall the application."</string>
<string name="text_3f2904435942cc0f" formatted="false">"The development version contains modifications that have not yet been released. The following link is the source code of the basic version."</string>
<string name="text_4338afaad52ea445" formatted="false">"Enable saving"</string>
<string name="text_c8bb8255390df3b6" formatted="false">"Enable privacy screen"</string>
<string name="text_c6ecaca362ae1c80" formatted="false">"Open-source components"</string>
<string name="text_6099db946bdb5ee4" formatted="false">"Open source software rights description"</string>
<string name="text_6e86570183c3cdd0" formatted="false">"This is currently the latest version"</string>
<string name="text_24a52788cf474bf6" formatted="false">"Currently, the remote end does not support direct input of this text. Please use a version of the controlled end that supports text input, or send it through the clipboard."</string>
<string name="text_33f25b500697a7d9" formatted="false">"Upon successful connection, the nearest remote device will be displayed here."</string>
<string name="text_70341256d7317e7d" formatted="false">"I have read and agreed to the Privacy Policy"</string>
<string name="text_85c4c8fda0461f35" formatted="false">"Selected file (cannot be read or insufficient space)"</string>
<string name="text_9b58c9ff5f6a856f" formatted="false">"The device ID was not found"</string>
<string name="text_97b7b18df07b9787" formatted="false">"Command sending failed, please try again"</string>
<string name="text_cb782027ca5986c7" formatted="false">"The command has been sent, waiting for computer confirmation..."</string>
<string name="text_3ca40a586103f6b7" formatted="false">"Authorization status"</string>
<string name="text_6e684586884ebee6" formatted="false">"Receive"</string>
<string name="text_84ae92e54e3078aa" formatted="false">"receive "</string>
<string name="text_d16488b987723161" formatted="false">"Local decoder"</string>
<string name="text_6b58198955259066" formatted="false">"Withdraw"</string>
<string name="text_c52e42ee856825db" formatted="false">"After withdrawing, the remote connection will be stopped, the screen preview will be closed and the existing preview will be cleared. The connection record and password will be retained, and authorization is required again before reconnecting."</string>
<string name="text_61f4788032ec596b" formatted="false">"Withdraw network authorization"</string>
<string name="text_e736e419008ac10a" formatted="false">"Withdraw network authorization?"</string>
<string name="text_73431dcf0afb7a65" formatted="false">"Collapse remote control menu"</string>
<string name="text_d826c4ece4dd4031" formatted="false">"Hide keyboard"</string>
<string name="text_6e9a457c0c3c34ed" formatted="false">"Hide mouse"</string>
<string name="text_5440f7424f2865bf" formatted="false">"Data"</string>
<string name="text_94244f41d0a41378" formatted="false">"File saving failed, please try again"</string>
<string name="text_2553a785fef2a405" formatted="false">"File saved"</string>
<string name="text_d573b3f9a810ea04" formatted="false">"File selection timed out and session ended"</string>
<string name="text_985a5c380be49f4f" formatted="false">"Text exceeds 128 KB"</string>
<string name="text_f33ac04eece6a6a6" formatted="false">"Disconnect"</string>
<string name="text_09b1395f50589327" formatted="false">"Will return to the homepage after disconnecting"</string>
<string name="text_a73c0680db0d2e06" formatted="false">"Disconnect remote connection?"</string>
<string name="text_a33f3804768e68ee" formatted="false">"Disconnect"</string>
<string name="text_47ceaf502c5a05d6" formatted="false">"New version"</string>
<string name="text_78823e6cf1a8afe5" formatted="false">"New version available: v"</string>
<string name="text_b7e58b67fa8f7e78" formatted="false">"New version available: v{0}"</string>
<string name="text_ef23787ec32c5588" formatted="false">"No unread announcements"</string>
<string name="text_dca2c418ac967366" formatted="false">"Unable to save reading status locally. Please try again."</string>
<string name="text_5c92da76360056cc" formatted="false">"Unable to delete this announcement. Please try again."</string>
<string name="text_615709dd3129313c" formatted="false">"Unable to initiate remote connection"</string>
<string name="text_a5c4f4ad79a640ae" formatted="false">"Unable to read open source license"</string>
<string name="text_1a13ea5910a0c5e6" formatted="false">"Display"</string>
<string name="text_ffe21a671190323e" formatted="false">"Show password"</string>
<string name="text_a94460e8a04f04c5" formatted="false">"Not now"</string>
<string name="text_6d9a7758610a2432" formatted="false">"No announcements"</string>
<string name="text_0b290c6600d92f43" formatted="false">"Recent connections"</string>
<string name="text_2388cbc6a486af9f" formatted="false">"Server"</string>
<string name="text_dfe408e686175095" formatted="false">"Server TLS certificate verification failed"</string>
<string name="text_d3716cc5a2f5a810" formatted="false">"Server Address"</string>
<string name="text_2a9608d4465d0f70" formatted="false">"Server login failed, please check the server address and network"</string>
<string name="text_d270868960e68629" formatted="false">"Server port"</string>
<string name="text_7a4e4a6a1b479a52" formatted="false">"Server connection timed out, please check the server address and network"</string>
<string name="text_f95ea7f4c063eea7" formatted="false">"Not enabled"</string>
<string name="text_c8f7ed6f2186fb98" formatted="false">"No browser found to open the link"</string>
<string name="text_94bc3d40defef3e9" formatted="false">"Unauthorized"</string>
<string name="text_4d0680f9efaef147" formatted="false">"Unread"</string>
<string name="text_d1fcb66a323a16f5" formatted="false">"Server not connected · Click to try again"</string>
<string name="text_7eea7966c0b746c5" formatted="false">"This application uses OpenFEC for forward error correction under CeCILL-C 1.0, which provides limited warranties and limits liability. See the full license for details."</string>
<string name="text_c611b4e50b0dbc08" formatted="false">"Local ID "</string>
<string name="text_97d10220f08dd7fc" formatted="false">"Local ID {0}"</string>
<string name="text_91f15d505770d7a0" formatted="false">"Source code and build instructions for this version"</string>
<string name="text_5b2c8ca2c5a87241" formatted="false">"Source revision is unavailable for this build."</string>
<string name="text_f0e274aa130e08a2" formatted="false">"Release to refresh"</string>
<string name="text_47077144715b7074" formatted="false">"Build instructions"</string>
<string name="text_837ab2d0b2f4cb98" formatted="false">"Build recipes and project patches"</string>
<string name="text_921c3b62f1f7d6f4" formatted="false">"View source code"</string>
<string name="text_fdd1059f10c4b979" formatted="false">"Check failed. Please try again later."</string>
<string name="text_7f68ebad19ba6bcd" formatted="false">"Check for updates"</string>
<string name="text_4f4d1e704e38c161" formatted="false">"Closing privacy screen…"</string>
<string name="text_2a05f6fbd3f4436e" formatted="false">"Establishing secure connection…"</string>
<string name="text_0da575e3c92de73a" formatted="false">"Turning on privacy screen..."</string>
<string name="text_91834aebecabcbc3" formatted="false">"Checking for updates…"</string>
<string name="text_0571a5f5f8ec4c83" formatted="false">"Clearing..."</string>
<string name="text_0e6336c8fe918730" formatted="false">"Obtaining local ID..."</string>
<string name="text_faebd20c2801fd0f" formatted="false">"Reading selected file..."</string>
<string name="text_694186911ae3e4da" formatted="false">"Connecting"</string>
<string name="text_c3ccc7f7d37533e3" formatted="false">"Connecting to signaling server..."</string>
<string name="text_4024d3c0f467a4f6" formatted="false">"Connecting to server..."</string>
<string name="text_46fe1ef52ec3a10f" formatted="false">"Reconnecting to server..."</string>
<string name="text_4e4fce649fc00238" formatted="false">"No file picker available for this device"</string>
<string name="text_bce2377283c2455a" formatted="false">"Clear"</string>
<string name="text_ee0e76b8507212de" formatted="false">"Clear previews"</string>
<string name="text_ecc809e44637de18" formatted="false">"Clear previews?"</string>
<string name="text_2782e4485ad23d34" formatted="false">"Source code and build instructions"</string>
<string name="text_1f566fe8f8b718f3" formatted="false">"Source code version"</string>
<string name="text_5f76b2bf82dd2c5d" formatted="false">"Version"</string>
<string name="text_16410ee9663fad10" formatted="false">"Copyright and License Statement"</string>
<string name="text_11de77e1e41e680f" formatted="false">"Computer keyboard"</string>
<string name="text_20f57900234debd7" formatted="false">"Quality"</string>
<string name="text_43b04aba53a54cf5" formatted="false">"Video Preference"</string>
<string name="text_82a5600c470179fa" formatted="false">"Video latency"</string>
<string name="text_33b458faaf967e15" formatted="false">"Video settings"</string>
<string name="text_afb5acdf8f07fadb" formatted="false">"Video Quality"</string>
<string name="text_12b41b7a3fbcf8ca" formatted="false">"Video Capture Frame Rate"</string>
<string name="text_66062971234f422d" formatted="false">"Trackpad"</string>
<string name="text_c421dc051bea3f11" formatted="false">"Relative mouse"</string>
<string name="text_de32e20193ad1042" formatted="false">"Got it"</string>
<string name="text_fac2a67ad87807c4" formatted="false">"OK"</string>
<string name="text_36f33adaf0942634" formatted="false">"Confirm"</string>
<string name="text_a5b7fba4dbd8f622" formatted="false">"Confirm connection"</string>
<string name="text_be1b4f3c6c1cda1e" formatted="false">"Offline"</string>
<string name="text_bfa4a6e0fe42ac28" formatted="false">"Space"</string>
<string name="text_471a579e8d87faac" formatted="false">"Port range is 1–65535"</string>
<string name="text_c7d0c77862e32b52" formatted="false">"Symbols &amp; keys"</string>
<string name="text_a562f07bd38c1a49" formatted="false">"Wait for the computer to return to the privacy screen state; older computers may not support it."</string>
<string name="text_5ee880e2524a983a" formatted="false">"Waiting for remote screen information"</string>
<string name="text_674c723a85ee7f57" formatted="false">"Waiting for the remote screen..."</string>
<string name="text_119f0483da4756c4" formatted="false">"Waiting for privacy authorization"</string>
<string name="text_7916071c1a8ee458" formatted="false">"System keyboard"</string>
<string name="text_02e9ba6fda719e2e" formatted="false">"Direct touch"</string>
<string name="text_8527c8c6d1ccc1ca" formatted="false">"Absolute Mouse"</string>
<string name="text_872428a7210d5e0c" formatted="false">"Network"</string>
<string name="text_4eafa9e925b30bcd" formatted="false">"Custom"</string>
<string name="text_dffd0c12b8f27247" formatted="false">"Virtual mouse"</string>
<string name="text_62c199f37bf95869" formatted="false">"The remote privacy screen was unlocked using the local shortcut. Lock it again to restore privacy and disable local shortcut unlocking."</string>
<string name="text_a569ce729c1d6e19" formatted="false">"The controlled version is too low, please upgrade to the latest version."</string>
<string name="text_d814476826827b78" formatted="false">"Remote encoder"</string>
<string name="text_488402883e27f6f0" formatted="false">"Text to be sent to the remote end"</string>
<string name="text_c20f7618d330a854" formatted="false">"Video"</string>
<string name="text_310c625425f014d8" formatted="false">"License"</string>
<string name="text_5c34f7d0e2124dca" formatted="false">"Licenses and copyright notices"</string>
<string name="text_e24134b643a17975" formatted="false">"The license and copyright notice are available offline."</string>
<string name="text_5e7628f043ce71d0" formatted="false">"Device identity failed to save, please check secure storage"</string>
<string name="text_5d35ee0d7945fcbc" formatted="false">"Device identity and selected saved access passwords are encrypted using Android Keystore. Connection records are only saved locally and are not included in device backup. The clipboard contents are cleared when the session is ended. Stay in session with picture-in-picture or connected notifications after leaving the app, and disconnect within the app or notifications."</string>
<string name="text_df3d58c7d84b85f2" formatted="false">"Settings"</string>
<string name="text_4f8771952222a011" formatted="false">"Settings, there is a new version"</string>
<string name="text_f4f712d5be91721a" formatted="false">"Access password"</string>
<string name="text_dd6c465847af1b60" formatted="false">"The access password is wrong, please re-enter it."</string>
<string name="text_defba1d2989bfab4" formatted="false">"Certificate verification failed · Click to try again"</string>
<string name="text_44f3d4d52347095a" formatted="false">"Please complete the online authorization first"</string>
<string name="text_2a07ca002bc5093d" formatted="false">"Please enable the CrossDesk system service on the remote end and try again"</string>
<string name="text_990fd23c62de5ccf" formatted="false">"The request timed out and the computer\'s privacy screen status has not been confirmed."</string>
<string name="text_e5c8fa6dcf7b2a8b" formatted="false">"Please wait for the current file to be sent."</string>
<string name="text_9305d9d72b06110d" formatted="false">"Please enter a port in the range 1–65535"</string>
<string name="text_3c7223dfb8dde5cf" formatted="false">"Enter a 9-digit device ID"</string>
<string name="text_285a0714894d2899" formatted="false">"Please enter a valid access password"</string>
<string name="text_c808bb77ffd2b4cd" formatted="false">"Could not apply settings. Try again."</string>
<string name="text_a762e7e650e285fa" formatted="false">"Software license"</string>
<string name="text_572cf45ba43634b3" formatted="false">"Back"</string>
<string name="text_7565f94031582c7c" formatted="false">"Return to control bar"</string>
<string name="text_55113b6925c362fd" formatted="false">"No connection record yet"</string>
<string name="text_3aa527b2106cc987" formatted="false">"This announcement has been updated or deleted, please return to the announcement list to view it."</string>
<string name="text_e357aa9a0fb28c16" formatted="false">"Remote task is closed"</string>
<string name="text_c255841f68096d3b" formatted="false">"Remote Desktop"</string>
<string name="text_133f4c7dabd88b97" formatted="false">"Remote desktop touch area"</string>
<string name="text_5931aeb3778f28f1" formatted="false">"Remote desktop: Touch and click, drag to move, press and hold the right button, pinch to zoom, zoom in and drag the screen with two fingers, slide up and down with two fingers to scroll at the original scale"</string>
<string name="text_3535087994d050de" formatted="false">"Remote computer"</string>
<string name="text_ed2e03072ce131de" formatted="false">"The remote window has been closed, or the background keepalive failed to start, and the session has ended."</string>
<string name="text_678b783fb578172b" formatted="false">"Remote connection"</string>
<string name="text_deccbd3eb9ba45f2" formatted="false">"Incorrect remote password"</string>
<string name="text_a2ce499295e358ae" formatted="false">"The remote end has closed the connection"</string>
<string name="text_176e117df3ace734" formatted="false">"The remote end is currently unavailable"</string>
<string name="text_d2c0694243f93249" formatted="false">"The remote access password has expired, please reconnect."</string>
<string name="text_a5574109f0208e89" formatted="false">"Connect"</string>
<string name="text_1d54bfbae5f8ac08" formatted="false">"Connection "</string>
<string name="text_89c4766afc85125f" formatted="false">"Connection failed"</string>
<string name="text_04bf1a61ba1369f6" formatted="false">"Connection failed · Click to try again"</string>
<string name="text_2ab7d89557e779ab" formatted="false">"Connection lost"</string>
<string name="text_c1d20bf4c3e471c1" formatted="false">"Connection ended"</string>
<string name="text_e57d2cbc0e9908e6" formatted="false">"Connection delay (RTT)"</string>
<string name="text_db4d4f0fab78498a" formatted="false">"Available when connected to a supported remote device."</string>
<string name="text_2123a9ed718f2c40" formatted="false">"Lock landscape screen after connection is established"</string>
<string name="text_f741e0536210eafa" formatted="false">"Connection prompt"</string>
<string name="text_485a26050ce57431" formatted="false">"Connection method"</string>
<string name="text_5a048351a77fe88d" formatted="false">"Connect to server"</string>
<string name="text_5854c4add7ab0f7a" formatted="false">"Connect to the server to get the latest announcements."</string>
<string name="text_32cd27b5a72515f3" formatted="false">"Connection timed out, please check the device ID, server and remote online status"</string>
<string name="text_6edc4b54cc1a9838" formatted="false">"Connect to remote desktop"</string>
<string name="text_d5407b4f3c666681" formatted="false">"Backspace"</string>
<string name="text_e02926784fcedb91" formatted="false">"Announcements"</string>
<string name="text_465a092680d132b3" formatted="false">"Some previews could not be cleared, please try again."</string>
<string name="text_b8784c8dd5636ff2" formatted="false">"Try again"</string>
<string name="text_a504fb6f162bdb20" formatted="false">"Retry saving reading status"</string>
<string name="text_ff9872a023e3a8aa" formatted="false">"Lock"</string>
<string name="text_864200bbb241e067" formatted="false">"Keyboard"</string>
<string name="text_1f0f014d4177a0cc" formatted="false">"Read and authorize"</string>
<string name="text_86651d17a401c55b" formatted="false">"Privacy"</string>
<string name="text_26c6e011e343708c" formatted="false">"Privacy and Authorization"</string>
<string name="text_6cad42fd3da85585" formatted="false">"Privacy screen"</string>
<string name="text_e157fafb76aad954" formatted="false">"Privacy screen off"</string>
<string name="text_bbef0eac0c04f214" formatted="false">"Privacy screen on"</string>
<string name="text_0c7535f7c5e43b6f" formatted="false">"Privacy screen unlocked"</string>
<string name="text_b62ac3dfb606c00b" formatted="false">"Privacy screen abnormality"</string>
<string name="text_598a2747dbd21dd5" formatted="false">"Privacy screen is temporarily unavailable"</string>
<string name="text_4d59be3c75189032" formatted="false">"Privacy screen status has expired, waiting for computer confirmation"</string>
<string name="text_1674532c70ed1db2" formatted="false">"Privacy screen status to be confirmed"</string>
<string name="text_e464b41fd5c45ba5" formatted="false">"Privacy Policy"</string>
<string name="text_554e957e8e3c00af" formatted="false">"The privacy policy cannot be read temporarily. Please refuse to connect to the Internet and reinstall the application."</string>
<string name="text_7db2e189ecb01065" formatted="false">"Mute"</string>
<string name="text_296c632ec857a0ba" formatted="false">"Audio"</string>
<string name="text_0cadb49096a382e0" formatted="false">"Project website"</string>
<string name="text_3beca60bd0c654f7" formatted="false">"Previews may contain personal or work information from the remote computer. After turning it on, each connection will automatically save a picture on the machine and display it in recent connections. You can close and clear at any time."</string>
<string name="text_b1c27820fec23edb" formatted="false">"High"</string>
<string name="text_844b8cc8dff7c1d8" formatted="false">"Default"</string>
<string name="text_4db8737054f3b0cc" formatted="false">"Off by default. When turned on, the screen will enter and remain in landscape orientation when the phone is connected; when turned off, it will follow the direction of the device. Tablets and multi-windows are not affected."</string>
<string name="text_1184a97edd8e1646" formatted="false">"Right mouse button, press and drag, release to end dragging"</string>
<string name="text_926eaa3af0b5e48e" formatted="false">"Left mouse button, press and drag, release to end dragging"</string>
<string name="text_269f39a07b4a4e01" formatted="false">"Mouse control"</string>
<string name="text_cba62aeeb4a63a23" formatted="false">"Mouse wheel, slide up and down to slide out of the key range"</string>
<string name="text_9aa238fc6121dede" formatted="false">", packet loss rate "</string>
<string name="text_0b5d16c21095fe10" formatted="false">", send "</string>
<string name="text_ba9a792d063a8c86" formatted="false">", password saved"</string>
<string name="text_f4c1f9d7896760a7" formatted="false">", receive "</string>
<string name="text_42237b5ec70c26f3" formatted="false">", click Retry"</string>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="zh-Hans" />
<locale android:name="zh-Hant" />
<locale android:name="ja" />
<locale android:name="ko" />
<locale android:name="de" />
<locale android:name="fr" />
<locale android:name="es" />
<locale android:name="pt-BR" />
<locale android:name="ru" />
</locale-config>
+5 -28
View File
@@ -7,35 +7,12 @@
#ifndef _CONTROLLER_PROTOCOL_H_
#define _CONTROLLER_PROTOCOL_H_
#include <minirtc.h>
#include <nlohmann/json.hpp>
#include <remote_action.h>
#include "../../../libs/controller/include/mobile_controller_protocol.h"
namespace crossdesk::android_controller {
inline std::string SecureAttentionMessage() {
RemoteAction action{};
action.type = service_command;
action.c.flag = send_sas;
return action.to_json();
using mobile_controller::SecureAttentionMessage;
using mobile_controller::PrivacyMessage;
using mobile_controller::VideoSettingsMessage;
using mobile_controller::NetworkReport;
}
inline std::string VideoSettingsMessage(int quality, int frame_rate, int preference,
uint32_t request_id) {
RemoteAction action{};
action.type = video_settings;
action.vs = {quality, frame_rate, preference, request_id, false};
return action.to_json();
}
inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, TraversalMode mode) {
const auto traffic = [](const MiniRtcInboundStats& in, const MiniRtcOutboundStats& out) {
return nlohmann::json{{"inbound", in.bitrate}, {"outbound", out.bitrate}, {"loss", in.loss_rate}};
};
return {{"rtt", stats.rtt_ms}, {"mode", mode}, {"srtp", stats.srtp_active},
{"video", traffic(stats.video_inbound_stats, stats.video_outbound_stats)},
{"audio", traffic(stats.audio_inbound_stats, stats.audio_outbound_stats)},
{"data", traffic(stats.data_inbound_stats, stats.data_outbound_stats)},
{"total", traffic(stats.total_inbound_stats, stats.total_outbound_stats)}};
}
} // namespace crossdesk::android_controller
#endif
+4 -260
View File
@@ -7,265 +7,9 @@
#ifndef _FILE_TRANSFER_SESSION_H_
#define _FILE_TRANSFER_SESSION_H_
#include <file_transfer_format.h>
#include <stream_names.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <mutex>
#include <optional>
#include "../../../libs/controller/include/mobile_file_transfer_session.h"
namespace crossdesk::android_controller {
using mobile_controller::FileTransferSession;
}
// File I/O is bounded to one chunk per pump. Feedback is delivered by MiniRTC
// callback threads; no callback waits for the RTC executor or for a remote ACK.
class FileTransferSession {
public:
using Clock = std::chrono::steady_clock;
using Send = std::function<int(const char*, const char*, size_t)>;
using Progress = std::function<void(const std::string&, double, bool,
const std::string&)>;
FileTransferSession(Send send, Progress progress)
: send_(std::move(send)), progress_(std::move(progress)) {}
~FileTransferSession() {
if (outgoing_) {
outgoing_->input.close();
std::error_code ec;
std::filesystem::remove(outgoing_->path, ec);
}
for (auto& entry : incoming_) Remove(entry.second);
}
void SetDirectory(std::filesystem::path directory) {
std::lock_guard<std::mutex> lock(mutex_);
directory_ = std::move(directory);
}
bool Start(const std::filesystem::path& path, const std::string& name,
Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
if (outgoing_ || name.empty() || name.size() > UINT16_MAX) return false;
std::error_code ec;
if (!std::filesystem::is_regular_file(path, ec)) return false;
const auto size = std::filesystem::file_size(path, ec);
if (ec) return false;
Outgoing file;
file.input.open(path, std::ios::binary);
if (!file.input) return false;
file.id = ++next_id_;
file.name = name;
file.path = path;
file.total = size;
file.updated = now;
outgoing_ = std::move(file);
progress_(name, 0, true, "");
return true;
}
void Pump(Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
for (auto it = incoming_.begin(); it != incoming_.end();) {
if (now - it->second.updated < timeout_) { ++it; continue; }
Reject(it->first, it->second.total, it->second.received);
progress_(it->second.name, -1, false, "");
Remove(it->second);
it = incoming_.erase(it);
}
if (!outgoing_) return;
auto& file = *outgoing_;
if (now - file.updated >= timeout_) { FinishSend(false); return; }
if (file.last || file.sent - file.acked >= 1024 * 1024) return;
std::array<char, kFileChunkSize> payload{};
const auto size = static_cast<uint32_t>(std::min<uint64_t>(payload.size(), file.total - file.sent));
if (size) {
file.input.read(payload.data(), size);
if (file.input.gcount() != size) { FinishSend(false); return; }
}
const bool first = file.sent == 0;
const bool last = file.sent + size == file.total;
const auto chunk = EncodeFileChunk(file.id, file.sent, file.total,
payload.data(), size, first ? &file.name : nullptr, first, last);
if (chunk.empty() || send_(kFileStream, chunk.data(), chunk.size()) != 0) {
FinishSend(false); return;
}
file.sent += size;
file.last = last;
}
void Ack(const char* data, size_t size, Clock::time_point now = Clock::now()) {
FileTransferAck ack{};
if (!DecodeFileTransferAck(data, size, &ack)) return;
std::lock_guard<std::mutex> lock(mutex_);
if (!outgoing_ || ack.file_id != outgoing_->id) return;
auto& file = *outgoing_;
if (ack.total_size != file.total || ack.acked_offset > file.sent ||
(ack.flags & ~3u)) return;
if (ack.flags & 2) { FinishSend(false); return; }
if (ack.flags & 1) {
if (file.last && ack.acked_offset == file.total) FinishSend(true);
return;
}
if (ack.acked_offset <= file.acked) return;
file.acked = ack.acked_offset;
file.updated = now;
Report(file.name, file.acked, file.total, true, file.percent);
}
void Receive(const char* data, size_t size, Clock::time_point now = Clock::now()) {
FileChunkView chunk;
if (!DecodeFileChunk(data, size, &chunk)) return;
const auto& h = chunk.header;
std::lock_guard<std::mutex> lock(mutex_);
auto it = incoming_.find(h.file_id);
const bool first = h.flags & 1, last = h.flags & 2;
const bool invalid = (h.flags & ~3u) || h.total_size > INT64_MAX ||
(first && h.offset != 0) || chunk.payload_size > kFileChunkSize ||
size != sizeof(FileChunkHeader) + h.name_len + chunk.payload_size ||
(!first && h.name_len) || (!chunk.payload_size && !last) ||
last != (h.offset + chunk.payload_size == h.total_size);
if (invalid || (it == incoming_.end() && incoming_.size() >= 4)) {
Reject(h.file_id, h.total_size, h.offset);
if (it != incoming_.end()) { progress_(it->second.name, -1, false, ""); Remove(it->second); incoming_.erase(it); }
return;
}
if (it == incoming_.end()) {
Incoming file;
file.name = SafeName(chunk.file_name);
file.total = h.total_size;
std::error_code ec;
if (!directory_.empty()) std::filesystem::create_directories(directory_, ec);
// A per-transfer directory avoids overwrites, even for duplicate names.
auto folder = directory_ / (std::to_string(h.file_id) + "-" + std::to_string(++receive_id_));
if (directory_.empty() || ec || !std::filesystem::create_directory(folder, ec)) {
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); return;
}
file.path = folder / file.name;
file.partial = folder / ".partial";
file.output.open(file.partial, std::ios::binary | std::ios::trunc);
if (!file.output) {
Reject(h.file_id, h.total_size, 0); progress_(file.name, -1, false, ""); Remove(file); return;
}
it = incoming_.emplace(h.file_id, std::move(file)).first;
}
auto& file = it->second;
if (h.total_size != file.total || (first && file.first) ||
!AddRange(file, h.offset, h.offset + chunk.payload_size)) {
Reject(h.file_id, h.total_size, h.offset);
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
}
if (first) {
file.first = true;
file.name = SafeName(chunk.file_name);
file.path = file.path.parent_path() / file.name;
}
file.last |= last;
// KCP removes messages in order, but delivers batches outside its lock on
// both RTP and timer threads. Track coverage instead of assuming callbacks
// arrive in order; ACK only the contiguous prefix and never expose holes.
file.received = !file.ranges.empty() && file.ranges.begin()->first == 0
? file.ranges.begin()->second : 0;
const bool complete = file.first && file.last && file.received == file.total;
file.output.seekp(static_cast<std::streamoff>(h.offset));
file.output.write(chunk.payload, chunk.payload_size);
if (complete) { file.output.flush(); file.output.close(); }
std::error_code ec;
if (complete && !file.output.fail()) std::filesystem::rename(file.partial, file.path, ec);
if (file.output.fail() || ec) {
Reject(h.file_id, h.total_size, h.offset);
progress_(file.name, -1, false, ""); Remove(file); incoming_.erase(it); return;
}
file.updated = now;
const FileTransferAck ack{kFileAckMagic, h.file_id, file.received, file.total, complete ? 1u : 0u};
const auto packet = EncodeFileTransferAck(ack);
send_(kFileFeedbackStream, packet.data(), packet.size());
if (complete) {
progress_(file.name, 1, false, file.path.string());
incoming_.erase(it);
} else Report(file.name, file.received, file.total, false, file.percent);
}
bool Sending() {
std::lock_guard<std::mutex> lock(mutex_);
return outgoing_.has_value();
}
private:
struct Outgoing {
uint32_t id = 0;
std::string name;
std::filesystem::path path;
std::ifstream input;
uint64_t total = 0, sent = 0, acked = 0;
bool last = false;
int percent = -1;
Clock::time_point updated;
};
struct Incoming {
std::string name;
std::filesystem::path path, partial;
std::ofstream output;
uint64_t total = 0, received = 0;
bool first = false, last = false;
std::map<uint64_t, uint64_t> ranges;
int percent = -1;
Clock::time_point updated;
};
static bool AddRange(Incoming& file, uint64_t start, uint64_t end) {
if (start == end) return true;
auto next = file.ranges.lower_bound(start);
if (next != file.ranges.end() && next->first < end) return false;
if (next != file.ranges.begin()) {
auto previous = std::prev(next);
if (previous->second > start) return false;
if (previous->second == start) { start = previous->first; file.ranges.erase(previous); }
}
if (next != file.ranges.end() && next->first == end) { end = next->second; file.ranges.erase(next); }
file.ranges.emplace(start, end);
return file.ranges.size() <= 1024;
}
static std::string SafeName(std::string name) {
const auto slash = name.find_last_of("/\\");
if (slash != std::string::npos) name.erase(0, slash + 1);
for (auto& ch : name) if (static_cast<unsigned char>(ch) < 32 || ch == ':') ch = '_';
if (name.empty() || name == "." || name == ".." || name == ".partial" || name.size() > 240) return "received_file";
return name;
}
static void Remove(Incoming& file) {
file.output.close();
std::error_code ec;
std::filesystem::remove(file.partial, ec);
std::filesystem::remove(file.path.parent_path(), ec);
}
void Reject(uint32_t id, uint64_t total, uint64_t offset) {
const auto packet = EncodeFileTransferAck({kFileAckMagic, id, offset, total, 2});
send_(kFileFeedbackStream, packet.data(), packet.size());
}
void Report(const std::string& name, uint64_t offset, uint64_t total, bool sending, int& previous) {
const double progress = total ? static_cast<double>(offset) / total : 0;
const int percent = static_cast<int>(progress * 100);
if (percent != previous) { previous = percent; progress_(name, std::min(progress, .99), sending, ""); }
}
void FinishSend(bool success) {
progress_(outgoing_->name, success ? 1 : -1, true, "");
outgoing_->input.close();
std::error_code ec;
std::filesystem::remove(outgoing_->path, ec);
outgoing_.reset();
}
const std::chrono::seconds timeout_{30};
std::mutex mutex_;
Send send_;
Progress progress_;
std::filesystem::path directory_;
std::optional<Outgoing> outgoing_;
std::map<uint32_t, Incoming> incoming_;
uint32_t next_id_ = 0;
uint64_t receive_id_ = 0;
};
} // namespace crossdesk::android_controller
#endif
#endif
+21 -1
View File
@@ -370,7 +370,8 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
auto* s = From(handle); if (!s || !s->controller) return;
auto action = MakeHostInformation("CrossDesk Android", {}, false, "1.6.0", HostPlatform::Android);
auto action = MakeHostInformation("CrossDesk Android", {}, true, "1.6.0", HostPlatform::Android);
action.i.supports_video_codec_status = true;
s->Send(kControlStream, action.to_json()); FreeRemoteAction(action);
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)(
@@ -392,6 +393,17 @@ extern "C" JNIEXPORT void JNICALL JNI_METHOD(nKey)(JNIEnv*, jclass, jlong handle
if (down) s->pressed_keys.insert(key); else s->pressed_keys.erase(key);
s->Send(kKeyboardStream, action.to_json());
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nText)(JNIEnv* env, jclass, jlong handle, jbyteArray bytes) {
auto* s = From(handle); if (!s || !bytes) return;
const auto size = env->GetArrayLength(bytes);
if (size <= 0 || static_cast<size_t>(size) > kMaxTextInputBytes) return;
RemoteAction action{}; action.type = text_input;
action.text.resize(size);
env->GetByteArrayRegion(bytes, 0, size, reinterpret_cast<jbyte*>(action.text.data()));
if (env->ExceptionCheck() || action.text.find('\0') != std::string::npos) return;
// Java supplies standard UTF-8, including supplementary Unicode characters.
s->Send(kKeyboardStream, action.to_json());
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nControl)(
JNIEnv*, jclass, jlong handle, jint type, jint value) {
auto* s = From(handle); if (!s) return;
@@ -418,6 +430,14 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nSecureAttention)(JNIEnv*, jcla
const auto data = android_controller::SecureAttentionMessage();
return SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
}
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nPrivacy)(
JNIEnv*, jclass, jlong handle, jint flag, jboolean block_input) {
auto* s = From(handle);
if (!s || !s->controller || s->stopping) return false;
const auto data = android_controller::PrivacyMessage(flag, block_input);
return !data.empty() &&
SendReliableDataFrame(s->controller, data.data(), data.size(), kControlStream) == 0;
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nFileDirectory)(JNIEnv* env, jclass, jlong handle, jstring directory) {
auto* s = From(handle); if (s) s->files.SetDirectory(String(env, directory));
}
+3 -15
View File
@@ -7,21 +7,9 @@
#ifndef _VIDEO_TIMING_H_
#define _VIDEO_TIMING_H_
#include <cstdint>
#include "../../../libs/controller/include/mobile_video_timing.h"
namespace crossdesk::android_controller {
inline double VideoLatencyMilliseconds(uint64_t captured_us,
uint64_t received_us,
int64_t submitted_us) {
// MiniRTC calibrates capture time when the frame is reassembled. Validate
// that mapping at reception, not after decoding/Surface waits: a real local
// backlog over five seconds must remain visible in the latency measurement.
if (!captured_us || received_us < captured_us || submitted_us <= 0 ||
received_us > static_cast<uint64_t>(submitted_us) ||
received_us - captured_us > 5'000'000)
return -1;
return (static_cast<uint64_t>(submitted_us) - captured_us) / 1000.0;
using mobile_controller::VideoLatencyMilliseconds;
}
} // namespace crossdesk::android_controller
#endif
#endif
+2 -9
View File
@@ -11,7 +11,6 @@ ANDROID = Path(__file__).resolve().parents[1]
ROOT = ANDROID.parents[1]
SOURCE = ROOT / 'deps/submodules/minirtc'
CACHE = ANDROID / '.native'
PACKAGE_REVISION = 'eda39de3fb99b420c168f1ab9ab2d1791e11b662'
XMAKE_MIN_VERSION = (3, 1, 1)
@@ -38,18 +37,12 @@ def main():
raise SystemExit('Initialize the MiniRTC submodule before building.')
env = os.environ.copy()
env['XMAKE_PKG_INSTALLDIR'] = str(CACHE / 'packages')
env['XMAKE_GLOBALDIR'] = str(CACHE / 'global')
env['ANDROID_NDK_HOME'] = str(ndk)
env['ANDROID_NDK_ROOT'] = str(ndk)
env['NO_COLOR'] = '1'
work = ANDROID / 'native'
repository = CACHE / 'global/.xmake/repositories/xmake-repo'
if not (repository / '.git').is_dir():
subprocess.run([xmake, 'repo', '--update'], cwd=work, env=env, check=True)
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip()
if revision != PACKAGE_REVISION:
subprocess.run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, check=True)
subprocess.run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, check=True)
# Use the desktop repository policy; Xmake manages recipe initialization
# and updates. Only installed Android packages/build products are isolated.
subprocess.run([xmake, 'f', '-P', str(work), '-y', '-p', 'android', '-a', 'arm64-v8a',
'-m', 'release', '--ndk=' + str(ndk), '--ndk_sdkver=26',
'--runtimes=c++_static', '--policies=package.precompiled:n',
+1 -3
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
"""Package existing reviewed notices for the Android runtime dependency set."""
import json
import hashlib
import os
from pathlib import Path
import subprocess
@@ -70,8 +69,7 @@ def write_pages(output, catalog, additional, revision, minirtc):
target = output / asset
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(document['text'], encoding='utf-8')
documents.append({'title': document['title'], 'asset': asset,
'sha256': hashlib.sha256(document['text'].encode('utf-8')).hexdigest()})
documents.append({'title': document['title'], 'asset': asset})
component['documents'] = documents
(output / 'ThirdPartyLicenses.json').write_text(json.dumps(
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
+1 -20
View File
@@ -2,7 +2,6 @@
"""Check the shipped native ABI, ELF load alignment and APK zip alignment."""
from pathlib import Path
import argparse
import hashlib
import json
import re
import struct
@@ -40,33 +39,15 @@ def verify(path):
name, extra = struct.unpack('<HH', raw.read(4))
if (library.header_offset + 30 + name + extra) % 16384:
raise ValueError('APK native library entry is not 16 KB aligned')
if 'assets/THIRD_PARTY_NOTICES.txt' not in archive.namelist():
raise ValueError('Missing third-party notices')
if archive.read('assets/PRIVACY.md') != (Path(__file__).resolve().parents[3] / 'PRIVACY.md').read_bytes():
raise ValueError('Missing or outdated bundled privacy policy')
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.json'))
source = json.loads(archive.read('assets/SourceMetadata.json'))
components = catalog['components']
ids = [item['id'] for item in components]
if catalog['schemaVersion'] != 1 or len(ids) != len(set(ids)) or not {
'crossdesk', 'minirtc', 'openfec', 'openh264', 'libiconv', 'android-ndk', 'svt-av1'}.issubset(ids):
raise ValueError('Incomplete About catalog')
for component in components:
if not component['documents']:
raise ValueError('Missing component documents')
for document in component['documents']:
text = archive.read('assets/'+document['asset'])
if not text or hashlib.sha256(text).hexdigest() != document['sha256']:
raise ValueError('Incomplete license document: '+document['asset'])
for document in component.get('buildDocuments', []):
if not archive.read('assets/'+document['asset']):
raise ValueError('Missing build document')
if source['schemaVersion'] != 1 or len(source['revision']) != 40:
raise ValueError('Missing source revision')
for document in source['buildDocuments']:
if not archive.read('assets/'+document['asset']):
raise ValueError('Missing source build instructions')
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, privacy policy, notices, About catalog and documents')
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, privacy policy and source metadata')
if __name__ == '__main__':
+33
View File
@@ -0,0 +1,33 @@
# CrossDesk CLI
CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通过真实 MiniRTC ID 会话观看远程桌面;默认只读、静音。包含 ANSI 象限字符画面、局部放大和平移、Kitty 图形后端,以及 Pi 自定义 TUI 扩展。
分发目标为 macOS / Linux(x64、arm64)和 Windows(x64)的原生终端客户端。npm 包发布后可统一使用:
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
```
统一入口包为 `@chunkdash/crossdesk-cli`,安装后命令仍为 `crossdesk-cli`。五个平台依赖使用 `@chunkdash/crossdesk-cli-*`,由启动器自动选择。旧的无 scope 平台包不属于这套依赖;仅安装平台包不会注册命令。是否可安装以 npm 上完整版本的发布结果为准,三端实机验收尚未全部完成。运行要求、打包、本地安装与发布步骤见 [npm 分发](docs/npm-distribution.md)。
在 CrossDesk 仓库根目录执行以下命令构建 CLI:
```sh
./apps/cli/scripts/prepare-deps.sh
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
xmake build crossdesk-cli
xmake run crossdesk-cli doctor --json
xmake run crossdesk-cli 123456789 --quality high --zoom native
```
`crossdesk-cli <设备ID> [选项]` 为默认连接方式,`view <设备ID>` 保留为兼容别名。最后一个设备 ID 是格式示例,使用时替换为你有权访问的设备。密码通过隐藏提示输入。只读时 `c` 开启控制、`q` 或 `Ctrl+C` 退出;点击目标或按 `d` 进入原始细节模式,`+/-` 或滚轮缩放,方向键或 `i` 上、`j` 左、`k` 下、`l` 右平移,`f` 回到完整桌面。任何模式下先按 `Ctrl+]` 进入持续本地命令模式,再按 `c` 选择返回后的控制状态、`q` 退出或视图按键连续调整画面,`Esc` / `Ctrl+C` 手动返回。控制模式中的 `Ctrl+T`、`Ctrl+C` 和普通按键均传给远端。ANSI 全屏缩略图仍受字符网格限制,默认请求 high 画质,阅读小字建议使用细节模式;支持 Kitty 协议的终端可使用 `--renderer kitty` 获得像素画面。
- [构建、使用、Pi 安装和故障排查](docs/terminal-client.md)
- [私有桥接协议](docs/bridge-protocol.md)
- [实际验证记录](docs/validation.md)
- [源码来源和依赖修补](docs/source-provenance.md)
CLI 仅作为控制端,每次连接通过服务端获得新的 `cli-xxxxxxxxx` 临时 ID。使用前须部署支持 `cli` 注册的 crossdesk-server;旧服务端不会回退为普通设备身份。
终端 I/O 包含 POSIX(Linux/macOS)和 Windows Console / ConPTY 实现。macOS arm64 已完整构建,之前已连接真实被控端验证 ANSI 观看与预览协商。Linux / Windows 全量构建和真实连接、SSH/Kitty 实机显示及真实 Pi TUI 仍需验收;交叉编译检查不等于完整运行验证。此项目不创建被控端虚拟桌面,不提供音频播放、文件传输、剪贴板同步或 MCP 服务。
+41
View File
@@ -0,0 +1,41 @@
# Bridge v1(CrossDesk 私有协议,不是 MCP)
启动:`crossdesk-cli bridge --stdio [--password-fd 3] [--server host] [--port 9099] [--quality low|balanced|high|ultra]`。
通过参数数组启动,不经 shell。stdin/stdout 为 UTF-8 NDJSON,每行一个完整 JSON。stdout 使用保留的描述符,底层 stdout 重定向到 stderr。宿主应消费/隔离 stderr,不能展示为视频行或模型消息。bridge 不要求 TTY。
每条请求含整数 `id` 和字符串 `method`。响应含同一 `id`、布尔 `ok`,成功时还含 `version:1`、完整 `status`;失败含固定 `error`(无效请求、版本或操作不可用等),不回显原输入/凭据。无有效 id 的错误为 null。事件没有 id。先 hello,再发送其他操作。
| method | 字段 | 行为 |
|---|---|---|
| hello | version:1 | 协议版本握手 |
| viewport | columns:1..240, rows:1..100, colors:8/24/256, utf8:boolean, fps:1..30;可选 cell_aspect:0.2..2, detail:quadrant/half, quality:low/balanced/high/ultra, zoom:1..32, native:boolean | 设置画面行数(不含状态栏)、字符宽高比、本地目标与预览档位;默认 cell_aspect=0.5、quadrant/high/1、native=false,保留未显式指定的 cell_aspect/detail/quality/zoom/native;不探测宿主 TTY |
| connect | device:9位数字字符串, display:0..63(默认0) | 从专用 password fd 读取至换行/EOF(最多256字节、等待5秒),开始异步注册/连接;ok不是远端已连接 |
| status | 无 | 返回当前状态 |
| control | enabled:boolean | 显式启停控制;关闭时释放输入 |
| input | data:string,最多16KiB | 终端原始按键/UTF-8/bracketed paste/SGR鼠标;只读时 c 开启控制、q/Ctrl+C 退出,d/鼠标点击进入细节,+/-/滚轮缩放,方向键或 i 上、j 左、k 下、l 右平移,f 全景;Ctrl+] 打开本地命令,随后 c 选择返回后的控制状态、q 退出或视图键连续调整画面,Esc/Ctrl+C 手动返回;控制模式中 Ctrl+T/Ctrl+C 传给远端,本地模式中再按 Ctrl+] 手动返回,并在返回控制模式时传递远端 Ctrl+] |
| display | index:0..63 | 选择已广告的零起始显示器,提升 generation |
| ack | frame_id、generation:非负整数 | 确认宿主消费帧;匹配当前等待帧才开放下一帧 |
| disconnect | 无 | 释放输入、结束当前媒体会话 |
| exit | 无 | 断开并结束进程 |
凭据不能放在 connect JSON 中。专用描述符可以由 Node `spawn` 的第四条 pipe 提供。成功读取后会话不会保留用户密码供上层访问;MiniRTC 内部为现有 Join/重连流程持有必要凭据直到销毁 Peer。下一次 connect 必须有可读的凭据来源;Pi 关闭视图后直接结束进程。
`viewport.fps` 是本地输出目标。对端支持预览订阅时,超过 15 的目标使用已有交互模式(协议 `fps=60`、编码 `frame_rate=30`),不再折算成 15 FPS;低目标仍按能力选择 1/8/15。bridge 始终输出字符行并要求宿主 ACK,不使用交互 Kitty 的共享内存传图。
帧事件:
```json
{"event":"frame","frame_id":42,"decoded_frame_id":38,"generation":4,"source_width":1280,"source_height":720,"zoom":2,"native":false,"source_rect":[320,180,640,360],"detail":"quadrant","columns":80,"rows":23,"cell_aspect":0.45,"lines":["..."],"queue_ms":3.2}
```
实际 lines 数为 rows,仅包含本地生成的 SGR 颜色序列、空格和 U+2580~U+259F 块字符,不含远端名称或任意终端指令。`frame_id` 是进程递增的输出序号,静止画面重绘仍产生新序号;`decoded_frame_id` 标识源解码帧,同源重绘可以相同。`source_width/height` 为实际解码尺寸,source_rect 为实际源裁剪矩形 [x,y,width,height]。native=true 表示原始细节模式,zoom 为当前模式下的倍率;generation 在关闭、重新连接和显示器切换时变化。viewport 变化可能与已写出的旧尺寸帧交错,宿主应丢弃不匹配行列数/cell_aspect 或旧 generation 的帧并释放对应 ACK。宿主返回的 ACK 表示消费/布局,不表示屏幕物理显示完成。
`cell_aspect` 为单个字符的宽/高,必须是 0.2~2 范围内的有限 JSON 数值;例如 9×20 像素字符应传 0.45。宿主负责测量或提供手动设置,bridge 根据该比例计算图像布局与鼠标映射,响应及状态事件中的 `status.cell_aspect` 返回当前值。该字段为 v1 的可选扩展;省略时保留上次值,首次默认 0.5。旧版 bridge 的帧可能不含此字段。修改比例也会触发静止画面重绘,新的鼠标映射仍在帧 ACK 后生效。
状态事件每秒最多一次:`{"event":"state","status":{...},"output_bytes":123,"submitted_frames":3,"elapsed_seconds":1.5}`。status 含 registering/connecting/connected/failed/closed/idle、client_id(当前或刚结束会话的 `cli-xxxxxxxxx`,注册前为空)、error、control、generation、显示器、文本/预览能力、预览确认状态、请求的 preview_width/height/quality/bitrate_bps、帧计数与网络统计。接收/提交 FPS 由相应计数的增量除以时间增量计算。
状态和成功响应中的 `status.command_mode` 表示处于持续本地命令模式,此时 `control=false`,远端输入暂停;`status.resume_control` 表示手动返回后是否启用控制。执行命令、未知按键或粘贴不会退出此模式,失焦或输入溢出只会将返回模式设为只读。`Esc` / `Ctrl+C` 或再次 `Ctrl+]` 手动返回;`status.exit_requested` 表示用户按键请求退出,宿主可据此关闭组件。宿主应从 `input` 响应立即更新命令提示,不必等待下一次状态事件;不要自行截获 `Ctrl+]` 退出。显式 `control` 请求只更新所选控制状态,在本地模式中仍暂停远端输入。新建连接或断开会重置本地模式。具体快捷键见 [终端客户端](terminal-client.md)。
边界:输入每条及当前解析缓冲上限64KiB、嵌套32层;响应队列64条;输出一条在途消息上限2MiB;1MiB/s;最多一条待 ACK 帧;一个最新待渲染 NV12 加一个最近画面的重绘缓存。协议错误明确响应;超限缓冲后结束。EOF 清理会话,完整的已收请求按序处理,不完整末尾报错;最多两秒排空输出后退出。宿主应持续消费响应,不能无限发送而不读取。失去连接后的释放只能尽力发送。
没有 `screenshot`、`audio`、文件、剪贴板、任意远端动作或 shell 命令方法;未知方法报错。模型接入者必须另行取得截图/自动操作授权,不能把持续帧事件附加到聊天记录。
+198
View File
@@ -0,0 +1,198 @@
# npm 分发与平台兼容性
## 使用方式
完整版本发布后的统一入口如下。构建产物或部分平台包上传成功,不代表入口包和全部平台依赖已经可用;发布者须完成本文的注册表检查。
```sh
npx @chunkdash/crossdesk-cli@latest doctor --json
npx @chunkdash/crossdesk-cli@latest 123456789
npm install -g @chunkdash/crossdesk-cli
crossdesk-cli 123456789
```
需要 Node.js 22 或以上和 npm。启动器根据 Node 的 `process.platform` / `process.arch` 选择同版本的 `@chunkdash` 平台包,直接启动原生 C++ 程序,继承终端、工作目录、环境变量和命令参数。安装过程不下载外部可执行文件、不执行 postinstall、不在用户机器编译。不要使用 `--omit=optional`,否则对应平台包不会安装。
入口包名为 `@chunkdash/crossdesk-cli`,`bin` 声明的终端命令仍为 `crossdesk-cli`。如已全局安装旧的无 scope 入口,先执行 `npm uninstall -g crossdesk-cli`,再安装 scoped 入口,避免两个包争用同一个命令。原来的 `crossdesk-cli@0.1.0` 是独立的旧包,改名不会更新它或自动迁移已有安装。
平台包仅提供原生二进制,不声明 `bin` 命令。`npm install @chunkdash/crossdesk-cli-linux-x64` 不会让 shell 出现 `crossdesk-cli`;普通用户应安装上面的通用入口。入口包的本地安装将命令放在 `node_modules/.bin`,全局安装才会放入 npm 的全局命令目录。
## 平台与终端
| 平台 | 原生包 | 终端适配与运行边界 |
| --- | --- | --- |
| macOS Apple Silicon | `@chunkdash/crossdesk-cli-darwin-arm64` | POSIX TTY;具体最低系统版本写入包内 `build-info.json` |
| macOS Intel | `@chunkdash/crossdesk-cli-darwin-x64` | POSIX TTY;最低系统版本同上 |
| Linux x64 | `@chunkdash/crossdesk-cli-linux-x64` | glibc、libstdc++、zlib;所需 GLIBC / GLIBCXX 版本写入 `build-info.json` |
| Linux arm64 | `@chunkdash/crossdesk-cli-linux-arm64` | 同上 |
| Windows x64 | `@chunkdash/crossdesk-cli-windows-x64` | Windows 10 或以上,原生 Console / ConPTY 与 VT 输出 |
`apps/cli/npm/platforms.json` 的 `packageName` 是打包脚本、入口包依赖和启动器共同使用的名称来源。Windows 的内部目标键仍为 `win32-x64`,包内 `os` 仍为 `win32`;只将 npm 包名写为 `windows-x64`,不能把 Node 平台标识也改成 `windows`。Scope 用于明确组织归属,不保证免于 npm 的名称或反垃圾审核。
这些是构建与分发目标,不代表均已实机通过。当前完整构建与 npm 运行验证只覆盖 macOS arm64;CLI 主程序与平台 I/O 已通过 Windows x64 与 Linux x64、arm64 的交叉编译检查。其他平台的完整 MiniRTC 链接、实际视频连接、键鼠、终端恢复仍需对应机器验收。
普通终端使用 ANSI 渲染,按能力选择 TrueColor / 256 色 / 基础色;Kitty 图形协议是可选增强。macOS Terminal、iTerm2、Linux 常用终端、Windows Terminal 和 VS Code 集成终端属于目标场景。SSH 应分配 PTY(`ssh -t`);tmux / screen 使用 ANSI。不支持 `TERM=dumb` 或把交互观看输出重定向到文件;非交互宿主使用 `bridge --stdio`。Alpine / musl 当前没有预编译包。Windows 独立 mintty 窗口可能不提供 Console / ConPTY,应使用 Windows Terminal 承载 Git Bash。
CI 的 macOS 构建配置指定 `--target_minver=13.0`,但最终必须检查实际产物及依赖。当前开发机的本地二进制最低要求为 **macOS 27.0**,不应将这个本地产物宣称为旧 macOS 通用版。
## 生成包
先按 [构建说明](terminal-client.md) 构建 release。macOS 打包前需要签名,CI 使用 ad-hoc 签名;本地可执行以下命令(Intel 将 `arm64` 换成 `x86_64`):
```sh
codesign --force --sign - build/macosx/arm64/release/crossdesk-cli
```
然后生成 npm 包:
```sh
npm --prefix apps/cli run pack:npm
```
脚本读取 `apps/cli/package.json` 与 `apps/cli/xmake.lua` 的版本,检查二者一致;确认源码中的 MiniRTC 日志脱敏补丁已应用,再检查当前平台二进制格式、CPU、动态链接依赖,并运行离线 `doctor --json`。macOS 只接受系统动态库,Linux 只接受列明的基础系统运行库,Windows 拒绝未随产品处理的 DLL 和动态 MSVC 运行库。Windows 在含 `dumpbin.exe` 的 Visual Studio 开发者环境中运行打包。脚本预先检查两个输出文件是否存在,并在临时目录中完成两个包后再写入输出目录;写入失败时清理本次已写入的包,已有同名产物不会被覆盖。
默认从 `build/<platform>/<arch>/release/` 读取原生程序,将两个 `.tgz` 写入 `build/npm/`。可指定路径:
```sh
npm --prefix apps/cli run pack:npm -- --binary /absolute/path/crossdesk-cli --out-dir /absolute/path/output
```
每台构建机生成当前平台的原生包与相同版本的通用启动包。以 macOS arm64、版本 0.1.0 为例,文件名为 `chunkdash-crossdesk-cli-darwin-arm64-0.1.0.tgz` 和 `chunkdash-crossdesk-cli-0.1.0.tgz`;npm 将 `@scope/name` 的 tarball 文件名写作 `scope-name-version.tgz`。五个 CI artifact 各含一份入口包,最终需要五个平台包和一份入口包,共六个 tarball。
`apps/cli/npm/platforms.json` 集中定义五个平台;脚本校验平台包名合法且不重复,启动包通过精确版本的 `optionalDependencies` 让 npm 仅安装匹配系统和 CPU 的原生包。生成的六个包都包含作者、源码仓库、主页、问题反馈地址,以及面向 npm 官方注册表的公开发布配置。原生包包含二进制、运行要求、SHA-256,以及当前控制端和已应用修补的 MiniRTC 源码快照。外部 Xmake 依赖的许可证、对应源码和重链接材料需要随正式版本一并提供,参见 [源码来源](source-provenance.md)。源码快照已含 MiniRTC,解包后无需运行 `prepare-deps.sh`;在 `source/` 目录按上述构建说明显式关闭桌面、开启 CLI,再构建 `crossdesk-cli`。源码包仅包含 CLI 及其依赖,不包含桌面或移动应用。
`apps/cli/package.json` 的 `private: true` 表示源码目录不是发布产物。只发布打包脚本生成的 tarball;生成的包不带 private 标记。
## 本地安装验证
以 macOS arm64 为例,用生成包的绝对路径,在工程外的临时目录执行:
```sh
npx --yes --offline \
--package /absolute/path/chunkdash-crossdesk-cli-darwin-arm64-0.1.0.tgz \
--package /absolute/path/chunkdash-crossdesk-cli-0.1.0.tgz \
-- crossdesk-cli doctor --json
```
也可先 `npm install --ignore-scripts` 安装这两个本地 tarball,再使用安装目录中的 `crossdesk-cli`。验证交互观看需要真实 TTY 与授权的被控端,doctor 不会建立远程连接。
Pi 等宿主应直接启动原生程序以可靠控制进程和专用密码描述符。全局安装后,用 `crossdesk-cli --print-binary-path` 获取路径,再将该路径设为 `CROSSDESK_CLI`;不要把整条 npx 命令放进此变量。
## CI 与发布
`.github/workflows/build-cli.yml` 配置五个平台的原生 release 构建、doctor 和打包,并上传 tarball。普通 push、PR 和手动构建只生成 artifact;`.github/workflows/release-cli.yml` 复用同一构建流程,再汇总并发布六个 npm 包。`cli-v*` 标签只进入 CLI 发布工作流,不重复触发独立 CLI 构建或桌面发布。更新 MiniRTC 引用时,必须先将对应提交推送到子模块远端,再推送 CrossDesk,否则全新检出和 CI 会在拉取子模块时失败。
### GitHub Actions 自动发布
| 操作 | 结果 |
| --- | --- |
| 推送普通提交 / 提交 PR | 构建五个平台、上传 artifact,不发布 npm |
| Actions → Publish CLI to npm → Run workflow,保持 `dry_run` 勾选 | 完整构建并执行六个包的发布预演,不写入 npm |
| 推送 `cli-v0.1.1` | 校验源码版本为 `0.1.1`,构建并发布到 `latest` |
| 推送 `cli-v0.2.0-rc.1` | 校验源码版本为 `0.2.0-rc.1`,构建并发布到 `next` |
| 在对应 `cli-v*` 标签上手动运行并取消 `dry_run` | 执行真实发布;分支上不允许关闭预演 |
准备下一版时同步修改 `apps/cli/package.json` 的 `version` 与 `apps/cli/xmake.lua` 的 `set_version`,提交并推送代码后创建同版本标签。例如源码已更新为 `0.1.1` 时:
```sh
git tag cli-v0.1.1
git push origin cli-v0.1.1
```
已经上传过 `0.1.0` 的包不能被新构建覆盖;下一次原生程序发布应使用新版本。手动预演使用 `npm pack --dry-run` 加上内容校验,不调用 `npm publish` 或交换 OIDC 凭据;可对分支运行,也允许检查已发布的版本,但不验证 npm 发布权限。标签运行始终校验版本一致性。GitHub 的手动运行入口需要该工作流先进入默认分支。
发布 job 只下载当前运行的五个 `npm-*` artifact,并保留独立目录。`release-npm.cjs` 校验包名、版本、平台、入口依赖、原生程序 SHA-256 和 tarball SHA-512;五份入口包的文件内容必须一致(允许 Windows CRLF 差异),实际采用 Linux x64 构建生成的入口,保留 Unix 启动权限。真实上传前先检查所有包的注册表状态,同版本内容不同或目标 dist-tag 已指向更新版本就停止。
上传顺序为五个平台包 → 等待其精确版本、完整性及 dist-tag 可查询 → 入口包 → 最终确认。npm 返回异步处理时,每 15 秒查询一次,每批最多等待 15 分钟;不会因查询不到版本就重复上传。真实发布带 provenance,使用 OIDC 短期凭据,只有发布 job 获得 `id-token: write`,整个流程不需要 `NPM_TOKEN`。发布 job 串行执行,不中断正在上传的版本。
如果发布 job 失败,优先使用 GitHub 的 **Re-run failed jobs**,复用本次已成功构建的 artifact。已存在且完整性、目标 dist-tag 一致的包会跳过;同版本不同内容、仍为 `0.0.0-stage` 的占位包、403 或网络错误都会停止并给出原因。等待 npm 处理完成或修复权限后再重跑,不要直接重跑全部构建;重新编译可能产生不同字节。如果已有版本的 dist-tag 与本次目标不一致,需要先人工检查并调整标签。本工作流不自动修改已有版本的 dist-tag,也不提供 `next` 到 `latest` 的推广操作。
### npm 账号一次性绑定
在下面六个包各自的 npm **Settings → Trusted Publisher** 中选择 **GitHub Actions**,不是在 npm 账号菜单中授权 GitHub:
- `@chunkdash/crossdesk-cli`
- `@chunkdash/crossdesk-cli-darwin-arm64`
- `@chunkdash/crossdesk-cli-darwin-x64`
- `@chunkdash/crossdesk-cli-linux-arm64`
- `@chunkdash/crossdesk-cli-linux-x64`
- `@chunkdash/crossdesk-cli-windows-x64`
六个包使用同一组配置:
| npm 字段 | 值 |
| --- | --- |
| Organization or user | `kunkundi`(GitHub 仓库所有者,不是 npm scope) |
| Repository | `crossdesk` |
| Workflow filename | `release-cli.yml`(只填写文件名) |
| Environment name | `npm` |
| Allowed actions | 勾选 **Allow npm publish**;本流程不需要 **Allow npm dist-tag** |
工作流使用 GitHub-hosted runner、Node.js 24 和 npm 12.2.1。GitHub 仓库的 `npm` environment 名称必须与 npm 配置完全一致,可在仓库 Settings → Environments 中限定只有 `cli-v*` 标签能部署;如设置此限制,分支预演也会被该环境规则拦截,应选择对应标签预演。可按团队需要设置 required reviewers;这是 GitHub 的发布审批,与 npm 2FA 不同。
新建 Trusted Publisher 后应在 **2 天内完成首次真实发布**,否则配置过期,需要删除后重建;预演不算成功发布。因此建议先推送工作流并完成预演,再创建 npm 信任配置,随后发布一个新版本。设置时 npm 可能要求当前账号完成浏览器 / 2FA 认证,不能由 GitHub Secrets 替代。若 scoped 入口包还没创建,需要先按下文从本地发布一次,再配置它的 Trusted Publisher。该绑定只解决身份认证,不豁免 npm 的包名与反垃圾审核。
### 构建缓存
CLI 构建分别缓存 Xmake 工具和已安装的原生依赖。原生依赖使用 `XMAKE_PKG_INSTALLDIR` 指定的 runner 临时目录,不缓存工程的 `.xmake` 配置或 `build` 产物。缓存 key 包含 runner 类型、目标架构、镜像版本、Xmake 版本以及构建配置、依赖配方和补丁的内容;不包含运行编号,也不因 CLI C++ 源码、README 或 npm 元数据修改而失效。镜像更新可能包含编译器/SDK 更新,因此会重新生成缓存;依赖配置变化时只接受新的完整 key,不回退使用旧配方的库。
`Restore native dependencies` 会显示缓存是否命中。各平台的 `xmake f` 成功安装依赖后立即执行 `Save native dependencies`,后续 CLI 编译、签名或打包失败也能在下一次复用;依赖安装失败时不会保存不完整缓存。Linux 仅对镜像中缺失的系统工具执行 APT 更新和安装。首次运行、缓存被清理、镜像或依赖配置变化仍需下载/编译依赖;缓存命中不代表跳过 CLI 自身构建和打包。
### 本地首次发布与故障恢复
首次使用 `@chunkdash` 前,确认当前 npm 账号属于该组织且有目标包的发布权限。本地发布需要已启用的 2FA 或符合 npm 要求的发布凭据;组织 owner 身份不代替发布认证。
```sh
npm whoami --registry=https://registry.npmjs.org/
npm org ls chunkdash --json --registry=https://registry.npmjs.org/
```
先重新构建五个平台的包;旧的 `crossdesk-cli-<平台>` tarball 不会自动变成 scoped 包,仅重命名文件也不会改变包内名称。已经发布的旧平台包保持原样,新入口只依赖 `@chunkdash` 包。每次版本发布同步更新 `apps/cli/package.json` 和 `apps/cli/xmake.lua`;若某个目标包的版本已经发布,不能用新的内容覆盖该版本。
如果五个 `@chunkdash/crossdesk-cli-*` 平台包的 `0.1.0` 已经发布,此次仅将入口从 `crossdesk-cli` 迁移到 `@chunkdash/crossdesk-cli`,可以沿用新入口的 `0.1.0` 并依赖原来的五个平台版本。确认注册表中各平台版本的完整性后,只发布新的 `chunkdash-crossdesk-cli-0.1.0.tgz`;不要重发已有平台版本。后续原生程序更新仍需整套提升版本并重新构建。
下载并解出同一提交、同一版本的六个 tarball,在其目录内执行发布。以下以 0.1.0 为例,先检查打包内容,再逐个发布五个平台包到 `next`:
```sh
(
set -e
for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 windows-x64; do
npm publish "./chunkdash-crossdesk-cli-${target}-0.1.0.tgz" \
--dry-run --ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
done
npm publish ./chunkdash-crossdesk-cli-0.1.0.tgz \
--dry-run --ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
)
```
`--dry-run` 只做本地发布预演,不能证明包名会被注册表接受。检查通过后发布平台包:
```sh
(
set -e
for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 windows-x64; do
npm publish "./chunkdash-crossdesk-cli-${target}-0.1.0.tgz" \
--ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
done
)
```
若 npm 返回正在处理(HTTP 202),等待对应的确切版本可查询,不要立即重复上传。遇到 `403 Package name triggered spam detection` 时停止,保留已成功的包,向 [npm 支持](https://www.npmjs.com/support)提供包名、仓库地址、版本及错误日志申请复核;不要自动重试整轮发布。恢复时先查询各包的确切版本,核对注册表的 `dist.integrity` 与 `npm pack <本地.tgz> --dry-run --json --ignore-scripts` 的 `integrity`,只补发未成功的包;同版本内容不同则停止并提升整套版本。
全部原生包可查询、完整性一致后,才发布通用入口:
```sh
(
set -e
for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 windows-x64; do
npm view "@chunkdash/crossdesk-cli-${target}@0.1.0" version dist.integrity \
--registry=https://registry.npmjs.org/
done
npm publish ./chunkdash-crossdesk-cli-0.1.0.tgz \
--ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
)
```
再确认 `@chunkdash/crossdesk-cli@0.1.0` 的依赖和完整性,并在五个平台用 `npx @chunkdash/crossdesk-cli@0.1.0 doctor --json` 做安装验证;交互连接仍需实机验收。验收后按需将六个包的 `latest` 指向该版本。不要只凭 `--tag next` 假定首次发布的包不会出现 `latest`,应查询实际 `dist-tags`。缺失平台包时启动器明确报错,不下载别的平台程序或回退为源码编译。
依据:[npm bin / optionalDependencies / os / cpu](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/)、[npm scopes](https://docs.npmjs.com/about-scopes/)、[npm publish](https://docs.npmjs.com/cli/v11/commands/npm-publish/)、[Trusted Publishing](https://docs.npmjs.com/trusted-publishers/)、[npx](https://docs.npmjs.com/cli/v11/commands/npx/)、[GitHub runner 平台](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)、[Windows Console 模式](https://learn.microsoft.com/en-us/windows/console/getconsolemode)。
+15
View File
@@ -0,0 +1,15 @@
# 源码来源
CLI 从独立开发目录整合到 CrossDesk,入口、打包脚本、npm 启动器和 Pi 扩展位于 `apps/cli`,会话和终端渲染实现分别位于 `libs/controller`、`libs/terminal`。
- CrossDesk 仓库:`https://github.com/kunkundi/crossdesk.git`。独立 CLI 的协议代码原取自 `becd1c0437f6c4fad757052c70db1eba71cf312f`;整合时目标仓库基于 `2f57d0ab`,两边 wire 源文件一致,直接复用现有 `libs/wire`。
- MiniRTC:`https://github.com/kunkundi/minirtc.git`,版本由主仓库记录的 `deps/submodules/minirtc` 子模块提交固定。整合时采用的 `b9ee1fa7fe038fdf771f5dec1b1d561d78273f15` 基于整合前的 `cdcd0d8fb0e9b688f3bf6416fe52b634cf20d9ca`,仅增加下述四处凭据日志脱敏修改。
- `ControllerSession` 的流名称、HostInformation 和输入参考 Android `rtc_bridge.cpp`;身份使用单 Peer 的 `cli` 临时注册协议;终端适配不依赖 JNI、Surface 或 GuiRuntime。
- 帧缓存遵守 `MiniRtcVideoFrame` 回调借用契约,直接复制到有界 CPU 存储,不跨回调保留 native 指针。
- PreviewSubscription 复用 wire 实现与对端 capability 字段,不新增被控端协议。
`deps/patches/minirtc-credential-logs.patch` 是 CLI 所需的 MiniRTC 修补:Join、排队 Join、取消 Join、Leave 四个日志点只记录 `@` 前的 ID,不记录密码。`apps/cli/scripts/prepare-deps.sh` 按主仓库记录初始化子模块并幂等应用补丁,不另行校验提交哈希;更新 MiniRTC 时无需修改脚本中的版本。CLI 构建检查补丁已存在。共享子模块中的脱敏同样适用于桌面程序;补丁没有改变鉴权、TLS、TURN 或媒体协议。
npm 原生包携带 CLI、共享库、构建配置和已修补 MiniRTC 的源码快照,不复制桌面、移动端、测试或本机构建缓存。源码包保留仓库目录结构,在 `source/` 中配置 `--CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y` 后构建。
LICENSE 沿用 CrossDesk GPL-3.0;MiniRTC 子模块保留其 LGPL-3.0 和第三方声明。外部 Xmake 依赖的许可证、对应源码及重链接材料仍需随正式版本提供。
+177
View File
@@ -0,0 +1,177 @@
# 终端客户端
## 构建与安装
要求 Git、Xmake >= 3.1.1、C++17 编译器、系统 zlib 头文件/链接库,以及 MiniRTC 包构建使用的系统工具。macOS SDK 已提供 zlib;Linux 需相应开发包(如 zlib1g-dev);Windows 使用 Visual Studio C++ 构建工具,zlib 由 Xmake 获取并静态链接,运行库使用 MT。依赖需要可访问 Xmake 包仓库。Linux 使用 GCC/Clang;macOS 使用 Xcode 命令行工具。无需安装 Slint、SDL、X11、PulseAudio 或桌面采集 SDK。
```sh
./apps/cli/scripts/prepare-deps.sh
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
xmake build crossdesk-cli
xmake run crossdesk-cli doctor --json
```
`prepare-deps.sh` 按主仓库记录的提交初始化 MiniRTC 子模块,并应用凭据日志脱敏补丁,可重复执行。脚本不另行校验提交哈希,升级 MiniRTC 时只需更新子模块提交;如果补丁与新版源码不兼容,准备步骤仍会失败,需要同步调整补丁。构建会检查补丁,缺失时失败。仓库默认开启桌面构建、关闭 CLI。上述参数只构建 CLI;需要同时构建桌面和 CLI 时,将 `CROSSDESK_BUILD_DESKTOP` 设为 `y`。恢复默认桌面配置使用 `xmake f -y --CROSSDESK_BUILD_DESKTOP=y --CROSSDESK_BUILD_CLI=n`。所有命令均从仓库根目录执行。
构建产物在 `build/<platform>/<arch>/<mode>/crossdesk-cli`。可把对应目录加入 PATH 或复制可执行文件到自己的 bin 目录。Pi 需要直接启动该可执行文件,不能使用会输出构建日志的 `xmake run` 作为桥接程序。
macOS 的 MiniRTC 依赖仍链接 VideoToolbox/CoreMedia 和 GLib 声明的 AppKit 等系统框架;CLI 不创建 GUI、采集器或音频设备。硬件解码参数显式关闭。Linux 无需 DISPLAY/WAYLAND_DISPLAY;本轮尚未在 Linux 主机实际构建验证。
Windows 仅支持 x64,产物为 `build/windows/x64/release/crossdesk-cli.exe`;先在 Git Bash 中执行 `sh apps/cli/scripts/prepare-deps.sh`,然后在开发者 PowerShell 中执行上述 Xmake 命令,配置时额外指定 `-p windows -a x64`。在 Git Bash 中构建时同样必须指定 `-p windows`,避免被识别为 MSYS 平台。Windows 使用 Console / ConPTY 的原生键盘、鼠标事件和 VT 输出,保存并恢复控制台模式与代码页;无需 WSL。Windows Terminal、现代 PowerShell / CMD 和 VS Code 的 ConPTY 终端属于目标运行环境,仍需实机验收。Git Bash 的独立 mintty 窗口没有原生控制台时,应从 Windows Terminal 的 Git Bash 配置启动。
预编译 npm 包的用户无需构建工具,见 [npm 分发](npm-distribution.md)。
## 连接与身份
```sh
crossdesk-cli 123456789
crossdesk-cli 123456789 --renderer ansi --fps 8 --quality high --zoom native
crossdesk-cli 123456789 --renderer kitty --quality ultra --zoom native --display 0
crossdesk-cli 123456789 --control
crossdesk-cli 123456789 --server your-server.example --port 9099
```
直接将设备 ID 作为第一个参数即可连接;`crossdesk-cli view <设备ID> [选项]` 保留为兼容别名,支持相同选项。设备 ID 必须是 9 位数字,保留前导零,以上数字仅示例。默认信令地址沿用当前 CrossDesk:`api.crossdesk.cn:9099`。密码从本地终端隐藏输入(POSIX `/dev/tty`、Windows `CONIN$` / `CONOUT$`),不提供密码命令行参数。CLI 不读取、不修改桌面配置。每次连接直接用 `cli` 请求服务端分配临时 `cli-xxxxxxxxx` ID(后缀为 9 位数字),注册、Join 和媒体使用同一个 Peer。CLI 仅作为控制端,不申请普通数字身份或 `C-` 身份,也不保存本机密码或重连凭据。每次启动或重新连接都会申请新的 ID;断开后服务端按 Web 临时身份的规则清理注册记录。
需先部署支持 `cli` 注册的新版 crossdesk-server;旧服务端会登录失败,本客户端不会回退为普通设备身份。桥接 `status.client_id` 返回服务端分配的 ID。
流程为创建主控 Peer、注册流 → 以 `cli` 注册 → SignalConnected 返回 `cli-xxxxxxxxx` → Join → Connected → 现有 HostInformation 握手 → 显示器选择、能力协商 → 视频。Init 返回 0 不是远端连接成功。连接总超时默认 30 秒;信令断开后结束本次会话,用户重新连接,不自动循环重试密码。
交互界面区分 `Connecting to server`(连接信令)、`Connecting to device`(连接设备)、`Connected, waiting for first frame`(已连接,等待首帧)和 `Displaying`(正在显示)。首帧前显示居中的等待提示及秒数;连接阶段从发起连接计时,首帧等待阶段从连接成功重新计时。首帧完整写入终端后才切换到 `Displaying` 并清除等待提示。已连接但暂时没有画面不会因此判为连接失败,也不代表中继未启用。状态栏右侧为接收视频码率,收到网络统计后显示 `P2P` 或 `RELAY`,码率按 bps、kbps、Mbps 显示;初始码率为零或很低时仍继续等待视频。
保留 MiniRTC WSS 证书链与主机名验证、TURN 临时凭据下发及 SRTP 协商。未提供跳过证书检查的参数。SRTP 是否实际协商成功通过状态统计呈现;沿用现有 MiniRTC 对旧端的兼容性,不宣称所有旧端必然启用 SRTP。自建证书问题应修复证书/系统信任链。
## ANSI、Kitty 与 SSH
ANSI 默认使用四象限字符:每个字符从 2×2 样本选择误差最小的两个颜色和象限形状,比原先上下半方块多一倍水平采样点;每个字符仍只有前景/背景两色。使用 `--ansi-detail half` 可回到上下半方块以兼容字体。缩小时采用面积加权滤波,避免最近邻采样漏掉细线。支持 TrueColor、256 色和基础 8 色,256 色选择实际颜色立方体或灰阶条中更接近的颜色。环境的 COLORTERM/TERM 决定颜色模式;非 UTF-8 locale 使用背景色空格。TERM=dumb、非 TTY 输出明确拒绝交互连接,不输出全屏转义序列。
四象限模式将每个 RGB 通道变化均不超过 6/255 的低对比单元合并为纯背景色,减少平坦区域的字形接缝。其余单元在独立拟合后参考上下左右邻格的量化误差调整选色,保留源图的真实渐变,并在高对比边缘减弱约束;调整设有失真上限,超过低对比阈值且能精确表示的文字细线和双色边缘保持原样。这些处理自动应用于独立 CLI 和 Pi/bridge 的四象限画面,实际效果仍受终端字体和行距影响。
画面按字符单元比例等比缩放,留黑边及状态栏。交互连接通过终端字节流查询字符尺寸,支持通过 SSH 返回的回复:优先使用 `CSI 16 t` 的字符像素尺寸,否则组合 `CSI 14 t` 的完整文本区像素尺寸和 `CSI 18 t` 的完整行列数计算,兼容 GNOME/VTE 的文本区报告。计算后再扣除状态栏及画面行列上限,不把预留行当作图像高度。窗口改变时重新查询,每两秒刷新一次以覆盖字体/行距变化;回复到达后会重绘静止画面和更新鼠标映射。查询不阻塞连接;没有有效回复时使用系统提供的像素尺寸,再缺失时回退为宽高比 0.5。曾收到有效回复时保留最近有效尺寸,直到下一次有效回复。字符栅格的整数舍入仍可能产生小幅比例误差。格式依据 [XTerm 窗口尺寸报告](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) 和 [GNOME VTE 实现](https://github.com/GNOME/vte/blob/master/src/vteseq.cc)。
Windows 的可见传统控制台优先读取当前字体尺寸;Windows Terminal/ConPTY 使用显示终端的尺寸回复,不采用隐藏控制台宿主的字体值。不同终端版本可能不支持查询,或返回虚拟图形尺寸而非实际字体尺寸,此时需要手动校准。
默认 `--cell-aspect auto` 自动确定比例。如果终端屏蔽查询或需要手动校准,可设置 `--cell-aspect 0.45`,数值表示单个字符的**宽/高**,允许 0.2~2;例如字符为 9×20 像素时使用 0.45。画面被压窄时调小此值,被拉宽时调大。此参数优先于自动检测,同时用于全景、细节和鼠标坐标映射。
独立 CLI 每次只输出变化行中首尾变化单元之间的区间,光标或局部视频更新不再重写整行;一帧内复用颜色状态、按实际色板索引合并颜色设置,并省略纯背景空格的前景色指令。Pi/bridge 仍发送可独立绘制的完整字符行,同时使用上述颜色编码优化。四象限初始拟合使用整数子集求和计算同等精度的颜色和误差,边界连续性拟合复用加权样本,减少重复运算。
缩放在完整输出边界清理旧画面。上限 240 列、100 画面行;默认顶部预留一行状态栏、底部预留两行快捷键,另预留一列避免自动换行。顶部状态栏用反色区分,左侧为控制模式和连接进度,右侧为画面参数及网络信息;底部两行分别为会话和视图快捷键。视频和等待提示从顶部状态栏下方开始,鼠标坐标按视频区域映射,点击状态栏不会传给远端。按键分组使用统一列宽和左右留白,窄窗口自动减少列数、合并按键组并省略次要参数,累计帧数和输出字节数保留在日志中。控制模式下仅提示本地命令入口及后续操作,进入本地命令模式后才显示平移和缩放按键;终端高度不足时优先收起底部提示行。
快捷键行使用 `KEYS |`、`VIEW |` 等固定宽度标题,标题和按键组之间用竖线分隔;键名用 `<c>`、`<+/->` 等标签标识。宽窗口中的分组分隔线按列对齐,窄窗口保留标题和完整按键标签,按可用宽度合并或减少分组。
顶部状态栏以整数显示实时帧率(例如 `FPS 30`),约每秒按最近统计区间内完整输出到终端的帧数计算;没有新画面输出时更新为 `FPS 0`。该数值是实际提交速率,不是 `--fps` 目标值或终端 GPU 呈现频率;窄窗口优先省略画面参数和网络信息,保留帧率。
ANSI 在完整桌面缩略图中仍无法保留普通字号的文字。先增加终端列数(缩小终端字体或扩大窗口),阅读局部时用 `--zoom native`。只读全景中点击目标位置可直接进入该区域的细节模式;按 `d` 则以当前中心进入。细节模式依据视口和字符宽高比选择源区域,避免为了完整装入桌面而把文字缩小;ANSI 视野较小,借助方向键平移阅读。`--zoom 1..32` 是相对整桌面的倍率;在细节模式中 `+/-` 是相对原始细节尺寸的倍率。画面缩放完全在本地进行,控制鼠标坐标映射回原始桌面的正确区域。
auto 模式发送 Kitty 查询与设备属性查询,等待最多 250ms;没有有效 Kitty 回应就用 ANSI。显式 kitty 模式探测失败时报错。tmux/screen 中 auto 使用 ANSI,显式 kitty 报不支持;不修改用户终端配置。
本地 macOS/Linux 的 Kitty 模式会另用一个 1×1 像素的共享内存对象探测 `t=s`,最多等待 250ms;探测成功后,将未压缩 RGB 写入权限为 0600 的 POSIX 共享内存,仅通过终端字节流发送名字和显示命令。每次使用唯一名字,最多保留一个待确认的共享缓冲区(1920×1080×3 字节)。收到上传成功确认后才显示新图并释放缓冲区;上传报错、确认超过 500ms 或本地分配失败时,本次会话退回内联传输,继续保留旧画面。终端和 CLI 都会清理共享对象,退出时也会释放未确认的对象。SSH 中不尝试共享内存;Windows 暂用内联传输。
内联 Kitty 通过终端字节流发送 zlib 压缩的 RGB/Base64(每块至多 4096 字符),不使用远端临时图片文件。两条路径都交替使用图像 ID=31/32:以 `a=t` 上传到未显示的 ID,传完后以 `a=p` 创建 placement=1,再删除旧图。仅在整帧显示命令输出完成后交替 ID,上传和短写期间保留旧画面,退出时清理两个 ID。这样避免重传同一 ID 时终端先移除旧图造成的帧间黑屏。
依据终端报告的像素尺寸生成画面(未知时按 10×20 像素字符估算),上限 1920×1080。保留面积滤波、黑边、宽高比和裁剪坐标;原始细节模式按像素视口裁取源区域。共享内存路径使用可复用水平行计算的整数面积权重,避免逐像素重复浮点运算,也不再因 Base64 字节预算额外降采样。没有可靠终端像素尺寸时使用估算值,因此不承诺显示设备的物理像素一一对应。内联压缩结果超出单次输出预算时仍逐级降低尺寸;高熵视频的实际帧率可能低于目标。RGB 传输和压缩不丢失像素,不代表面积采样或上游视频编码无损。
```sh
ssh -t your-server
crossdesk-cli 123456789 --renderer ansi --fps 8
```
用户本地终端负责显示 SSH 字节流。ANSI、Kitty over SSH 和 SSH+tmux 的实机互通尚未验证。无 GUI 指主控客户端;被控端仍需要可捕获的图形会话。
## 预览与背压
`--fps 1..30` 是本地输出目标。交互 CLI 的 ANSI、内联 Kitty 和共享内存 Kitty 默认均为 30 FPS;共享内存失败回退时保持该目标。bridge 仍按宿主协议默认使用 8 FPS。
未显式指定 `--fps` 时,远端预览从 30 FPS 开始,根据完整一帧从转换开始到输出完成的耗时自动选择 8/15/30 FPS 请求;共享内存的上传确认等待也计入,等待源帧的空闲时间不计入。使用平滑估计、不同升降档阈值和至少 8 个新帧样本,持续变慢 2 秒才降档、持续恢复 4 秒才升档,保留供帧余量。15 FPS 仅在对端声明支持时使用;预览未获接受时不追加自动调节。视口变化、显示器切换和共享内存回退会重置耗时估计。显式 `--fps` 固定本地目标和对应的远端预览请求,关闭自动调节;例如 `--fps 30` 可用于与自动策略对比。
本地调度只在有新帧或需要重绘时消耗刷新时隙;短暂迟到保持原刷新节奏,长时间阻塞后跳过旧时隙,不连续补画过时帧。目标帧率不代表终端实际显示帧率。
对端声明 PreviewSubscription 时,预览请求不超过 15 映射到合法 1/8/15 FPS(15 需要对应能力);超过 15 则请求项目已有的交互模式:协议 `fps=60` 是模式标记,编码设置 `frame_rate=30` 才是帧率。分辨率上限使用协商到的 v2,视频预算只在 bandwidth 能力存在时发送。`--quality low|balanced|high|ultra` 对应如下请求:
| 档位 | 分辨率上限 | 质量枚举 | 视频预算 |
|---|---|---|---|
| low | 640×360 | 0 | 1Mbps |
| balanced | 1280×720 | 1 | 2.5Mbps |
| high(默认) | 1920×1080 | 2 | 6Mbps |
| ultra | 3840×2160 | 2 | 12Mbps |
这些是请求上限,实际视频尺寸与码率由被控端决定,ultra 不会把低分辨率源变成真实 4K。低带宽链路可显式选择 balanced/low。窗口缩小不会再把远端预览降到字符网格尺寸;选定清晰度用于后续局部放大。旧端继续基础观看并本地限帧缩放,不发送未声明的扩展。预览 request_id/ack/accepted 可从桥接状态检查;未确认或被拒绝不冒充成功,也不阻止基础视频。
媒体回调仅同步复制借用的 NV12 数据,校验尺寸、容量、native 描述符与 stride;不保留借用指针。上限单帧 16,777,216 像素、每边 8192、偶数尺寸。最多一个待转换帧,替换前先完成校验与复制,无效帧不会挤掉已有画面;播放器另保留最近转换的一帧用于静止桌面的本地放大/重绘,并在接收新帧时回收上一帧缓冲区供后续复制使用。只丢弃解码后图像。显示器切换/关闭提升 generation,旧帧失效。
转换与输出在拥有会话的事件循环,媒体回调独立。输出至多一个完整消息(2MiB),按令牌预算分块非阻塞写入,突发额度上限仍为 1MiB。本地 Kitty 使用 8MiB/s;存在 `SSH_CONNECTION`、`SSH_CLIENT` 或 `SSH_TTY` 时,Kitty 保持 1MiB/s,ANSI 和 Pi 桥接也保持 1MiB/s。不能开始新画面时保留最新解码帧。ANSI 差分基准只在整帧输出完成后提交。Pi 桥接另要求消费帧 ACK,最多一帧等待 ACK,输入映射只在对应 ACK 后切换到新网格。状态栏优先在完整画面输出之间更新,避免大图持续刷新时状态饥饿;可写事件唤醒短写重试,仍遵守字节预算。控制事件至多 64 条,协议输入 64KiB/嵌套 32 层,输入粘贴缓冲 32KiB,文本单次最多 16KiB。
测量像素渲染帧率时使用 release 构建;debug 的 `-O0` 面积采样开销明显更高。共享内存中的像素不经过终端字节预算,但仍受转换和终端消费速度限制;8MiB/s 是内联路径的输出上限,不是帧率保证。解码、像素转换、压缩、终端消费速度和帧大小都会影响结果。退出时,未完成画面最多继续写入 2s;取消图像传输及恢复屏幕的短写最多另重试 500ms。POSIX 立即恢复 termios 并清理输入,不等待输出排空;如果终端始终不消费输出,屏幕清理只能尽力发送。
统计包括收到帧数、被替换帧数、非法帧、等待帧数、实际完整提交次数、输出字节数、运行秒数、最近本地排队时间、网络码率/RTT/SRTP。通过计数差/时间差可得到区间 FPS;交互连接结束日志另给平均提交 FPS,以及实际 `renderer` 和 `output_bytes_per_second` 预算。`kitty_transport=shared_memory/inline/none` 标明生效路径,`target_fps` 是本地目标,`preview_fps` 是最近请求的远端预览目标(并非已生效或实测帧率),`adaptive_preview` 表示是否开启自动调节,`preview_adjustments` 记录切档次数;每次切档另记录 `preview_fps` 事件。`frame_service_ms` 是最近完整帧处理与输出耗时的平滑估计,`frame_processing_ms` 是平均本地布局/转换/传输准备耗时,`shared_ack_ms` 是平均上传命令写完到解析成功确认的耗时,`shared_fallbacks` 记录运行时回退次数。`client.log` 摘要的 `startup_ms` 记录从发起连接到连接成功(`connected`)、首个取出用于显示的视频帧入队(`first_frame`)、首帧显示命令完整写入终端(`first_output`)的毫秒数;未到达的阶段为 `null`。写入 TTY 完成不等于屏幕显示完成,本地排队时间不是端到端延迟。
## 输入与退出
默认只读、静音。`--control` 或只读时按 `c` 明确启用输入。`Ctrl+]` 进入持续的本地命令模式:先按下并松开,再按下表中的命令键,可以连续执行多个命令。状态栏即时显示当前模式与可用按键。
macOS 自带 Terminal 还需要在当前窗口勾选“显示 → 允许鼠标报告”(View → Allow Mouse Reporting)。CLI 会启用鼠标报告,但无法覆盖 Terminal 关闭此选项的设置;未勾选时,点击、拖动和滚轮事件不会传给 CLI。参见 [Apple 官方说明](https://support.apple.com/zh-cn/guide/terminal/trmlc69728a5/mac)。
| 操作 | 只读模式 | 控制模式 |
|---|---|---|
| 开启/关闭远端控制 | `c` | `Ctrl+]`,再按 `c` |
| 退出 CLI | `q` 或 `Ctrl+C` | `Ctrl+]`,再按 `q` |
| 进入本地命令模式 | `?` 或 `Ctrl+]` | `Ctrl+]` |
| 放大/缩小 | `+` / `=` / `-` | `Ctrl+]`,再按对应键 |
| 原始细节/完整画面 | `d`(detail)/ `f`(fit),相邻字母键 | `Ctrl+]`,再按对应键 |
| 平移 | 方向键或 `i` 上、`j` 左、`k` 下、`l` 右 | `Ctrl+]`,再按对应键 |
进入本地命令模式时暂停远端输入,并释放已记录按键/鼠标。执行缩放、平移、`c` 或未知命令后均保持此模式;`c` 切换手动返回后是否启用远端控制,状态栏显示 `CONTROL PAUSED` 或 `READ ONLY`。`Esc` 或 `Ctrl+C` 手动返回所选模式;`q` 退出 CLI。再次按 `Ctrl+]` 也会返回,并在所选模式为控制时向远端发送一次 `Ctrl+]`。命令模式中的鼠标事件被忽略,bracketed paste 内容被丢弃,均不会退出模式或触发快捷键。窗口失焦或输入缓冲区溢出时继续保持本地命令模式,但将返回模式设为只读。控制模式下普通 `c/q/?`、`Ctrl+T`、`Ctrl+C` 和单独 `Esc` 均传给远端。目前不支持重绑定。
只读时,`d` 或在画面中单击进入该位置的原始细节模式;`+` / `=` 放大、`-` 缩小(1~32倍),滚轮以鼠标指向为中心调整倍率。方向键或 `i/j/k/l` 分别向上/左/下/右平移,`f` 恢复完整画面。黑边点击被忽略。这些按键和鼠标在控制模式下仍传给远端;进入本地命令模式后可连续调整视图,直至手动返回。放大、平移和只读鼠标定位不会向远端发送操作,也适用于静止画面。
支持 SGR 和传统 `CSI M` 鼠标报告,包括移动、左右中键、拖动和滚轮;完整缓冲跨读取分片的传统报告,坐标字节不会作为按键执行。启动时依次启用普通、拖动和全移动报告,兼容只支持部分跟踪模式的终端;退出时关闭这些模式。传统报告的坐标上限为 223,较大窗口应使用支持 SGR 的终端。协议格式依据 [XTerm 鼠标协议](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking)。
点击和滚轮先发送对应位置的移动事件,不依赖终端提前上报移动;黑边不发送新点击,黑边处松开仍释放旧按钮。传统报告无法区分松开的按钮,因此释放本客户端记录的全部已按下鼠标按钮。输入坐标映射到所选显示器,`--display` 是 HostInformation 列表的零起始索引,视频流为 Display1、Display2……
控制模式显示画面后,状态栏的 `Mouse RX`(窄窗口为 `M:`)显示累计收到的有效鼠标报告数,即使画面中看不清远端指针也能检查输入。若点击或移动时计数一直为 0,检查本地终端的鼠标报告设置及 SSH 是否分配 PTY(`ssh -t`);若计数增长但远端仍无响应,退出后查看以下日志统计。窗口失焦会退回只读,重新聚焦后需再按 `c` 开启控制。
支持 UTF-8 文本与 bracketed paste(远端须声明 text_input),Enter、Tab、Backspace、Esc、方向、Home/End、Delete、PageUp/Down、F1–F4、Ctrl/Alt+英文字母。不支持的远端文本能力会提示,不把粘贴执行为本地 shell 命令。普通终端采用立即按下/释放的 tap 策略;不承诺持续按键、所有组合键、IME 或完整键盘布局。Pi 使用宿主 SDK 解码增强按键后转为这组基础输入。
失焦报告、关闭控制、切换显示器、退出和断开都尝试释放已记录输入;网络已中断时不能保证远端收到释放。正常退出在普通执行路径恢复终端模式、鼠标报告、光标、自动换行和备用屏幕。POSIX 同样处理 SIGINT/SIGTERM/SIGHUP;Windows 处理控制台 Ctrl+C / Ctrl+Break,恢复控制台模式与代码页。中止时最多等待两秒排空当前画面,随后发送取消/终止转义序列并恢复;输出连接彻底损坏或 SIGKILL 时无法保证恢复,可在 shell 执行 `reset`。
只读是本客户端行为限制,不是服务端强制的独立只读授权。没有文件/剪贴板/远端命令接收处理器。
## Pi 扩展
依据已下载的 `@earendil-works/pi-coding-agent` 与 `pi-tui` **1.4.0** SDK 实现和类型检查。旧的 `@mariozechner` 包未验证,缺少 ctx.mode 的宿主会降级。
```sh
export CROSSDESK_CLI="$PWD/build/macosx/arm64/release/crossdesk-cli"
pi -e ./apps/cli/plugins/pi/index.ts
```
在 Pi 输入 `/crossdesk` 或 `/crossdesk 123456789`。先输入 ID,再在自定义遮蔽组件输入密码。扩展使用独立 fd 3 写密码,随后关闭;不写配置、聊天、工具结果或模型上下文。查看组件与独立 CLI 使用相同的 `Ctrl+]` 命令前缀和上述模式按键,由 bridge 统一解析。插件默认请求 high 预览并显示 ANSI 象限字符行,支持同样的只读细节/放大/平移键,颜色深度依据终端环境回退,由 Pi 自身布局和刷新;不直接占用 TTY,不发送 Kitty 图形或自行打开鼠标报告。
Pi 扩展读取宿主的字符尺寸,并通过宿主终端写入/输入监听接口查询字符尺寸或完整文本区尺寸,每两秒及窗口变化时刷新;尺寸回复不会传给远端。得到的宽高比通过 bridge 的 `viewport.cell_aspect` 传入,比例变化时重绘静止画面,并丢弃仍在途的旧比例帧。自动检测不可用或不准确时,可在启动 Pi 前设置环境变量 `CROSSDESK_CELL_ASPECT=0.45`(宽/高,允许 0.2~2;`auto` 恢复自动检测)。视图关闭或 bridge 停止时移除查询定时器和输入监听。
需要全局发现时,把整个 `apps/cli/plugins/pi` 目录复制到 `~/.pi/agent/extensions/crossdesk/` 后重启 Pi 或执行 `/reload`。卸载时先关闭视图,删除该目录并 `/reload`;使用 `-e` 时从启动参数移除即可。无需修改包中的密码或设备配置。默认连接官方信令;自建信令当前通过独立 CLI 连接命令使用,插件尚无服务器设置界面。
仅在 `ctx.mode === "tui"`、hasUI 且进程双向 TTY 时打开组件。RPC/print/json 返回独立播放器提示,不声称内嵌视频。UI 关闭/会话退出先关闭 bridge stdin,超时 SIGTERM,再超时 SIGKILL。bridge 尚未就绪或已经停止时,组件可直接用 `q`、`Esc` 或 `Ctrl+C` 关闭。SDK 类型和模拟 UI 生命周期已验证,真实 Pi TUI 视觉刷新、增强按键与鼠标传递未实机验收。真实设备的 bridge 与独立 CLI 连接验证见 [验证记录](validation.md)。
官方依据:[扩展文档](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)、[TUI 文档](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/tui.md)、[Kitty 图形协议](https://sw.kovidgoyal.net/kitty/graphics-protocol/)。
## 故障排查与边界
- `incorrect_password`:重新启动并输入正确密码,不自动重复尝试。
- `device_not_found` / `device_offline_or_unavailable`:核对 ID、服务端和被控端在线状态。
- `signaling_login_failed` / `signaling_disconnected`:检查服务器、网络、代理、防火墙;确认服务端已支持 `cli` 注册。
- `cli_identity_not_supported`:服务端未返回有效的 `cli-` 加 9 位数字的身份,需升级或检查服务端。
- `tls_certificate_error`:检查证书链、主机名、机器时钟和系统 CA,不关闭验证。
- `connection_timeout` / `connection_failed`:检查 NAT/TURN 与被控端。TURN 凭据只取服务端下发值。
- `display_not_available`:换成远端声明的索引;单会话一次只显示一个屏幕。
- `media_initialization_failed`:检查编译依赖和本地媒体运行环境。
- 只读下按键不会发送到远端;远端未声明文本协议时文本输入不可用。
- 日志在进程创建的私有临时目录(POSIX `/tmp/crossdesk-cli-*`、Windows `%TEMP%\crossdesk-cli-*`)。交互连接的摘要在 client.log,包含收到的终端鼠标报告数 `mouse_reports`、其中的传统报告数 `mouse_legacy_reports`、成功交给传输层的鼠标事件数 `mouse_forwarded`、发送失败数 `mouse_send_failures`。一次点击报告可能生成定位和按键两条事件,所以发送数可能大于报告数;发送成功不表示被控端已执行。transport.log 只记录白名单中的 ICE/DTLS 状态及本地控制开关,不记录密码、SDP 或原始信令。使用完可删除。错误退出信息在恢复终端后显示,并给出本次日志目录。
- `connection_failed` 表示连接建立失败;`connection_lost` 表示连接成功后传输失效。xmake 随后的 `execv ... failed(1)` 表示程序以错误码 1 退出,本身不代表进程崩溃。遇到控制失效时,对照 transport.log 中的控制开关和连接状态;`mouse_reports` 为 0 表示终端没有向 CLI 提供可识别的鼠标事件。报告数增长但 `mouse_forwarded` 为 0 时,检查是否仍为只读/本地命令模式、是否已有画面,以及点击是否落在视频区域;`mouse_send_failures` 增长时检查连接状态。
- 如果整个 macOS Terminal 窗口退出且 CLI 记录 `output closed`,还应检查系统 DiagnosticReports 中的 Terminal 报告。已观察到 Terminal 2.15 在创建标签页并调整窗口尺寸时发生 `TTLogicalScreen expandUnicharOffsetCacheToLine:` 行缓存越界;这与 CLI 的 `connection_failed` 不同。临时可在独立窗口运行,避开该标签页操作,不能据此认为 Terminal 缺陷已修复。
- `doctor --json` 是环境报告,不会联网登录、主动探测 Kitty 或证明远端可连接。 `input_shortcuts=local_commands_v2` 表示支持本文的持续本地命令模式;启动日志同时记录实际执行路径,以便区分不同工程的构建产物。
- 画面比例异常时查看 `client.log` 摘要的 `terminal_geometry`:`cell_aspect` 为实际采用的字符宽高比,`reported` 表示收到有效的终端尺寸报告,`manual_aspect` 表示使用了手动覆盖。`doctor` 中的比例和像素尺寸只反映系统 TTY 信息,不主动查询终端,可能与交互连接自动检测后的值不同。
第一轮无音频播放或音频订阅 API,SSH 服务器上的声音不会传回本地。无后台守护进程、共享媒体 Broker、IP 直连、MCP 或自动截图。其他宿主可以使用 [私有桥接](bridge-protocol.md),没有可靠自定义 UI 时打开独立终端或给出播放器命令。
每个播放器/桥接进程只拥有自己的一个媒体会话,不向模型消息发送视频。
+129
View File
@@ -0,0 +1,129 @@
# CLI 验证记录
## 本地仓库整合(2026-10-30)
环境为 macOS arm64、Xcode SDK 27.0、Xmake 3.1.1、C++17,关闭 CUDA。CLI 源码整合到 CrossDesk 的 `apps/cli`,共享现有 wire 与 MiniRTC;未新增或修改工程内测试。
- CLI 单独配置和 release 构建通过,目标为 `crossdesk-cli`;`doctor --json` 返回有效 JSON,动态链接依赖仅为系统库及框架,无 Slint / SDL。
- 同时启用桌面与 CLI 时,debug 配置下 `crossdesk`、`crossdesk-cli` 均编译通过。
- 原有仓库结构、wire 和键盘状态测试通过,未修改测试源码。
- 工程外桥接检查通过:JSON 协议、版本校验、分片、EOF、消息上限、慢消费者、本地信令失败、凭据描述符和非 TTY 拒绝。
- 工程外 POSIX PTY 检查通过:隐藏密码、无 DISPLAY/WAYLAND_DISPLAY、原始模式恢复、Kitty 探测超时和 tmux 下明确拒绝 Kitty。
- macOS arm64 原生 npm 包和通用启动包生成成功;离线安装、启动器 `doctor` 和原生程序路径解析通过。该本地产物最低要求为 macOS 27.0。
- 从原生包解出源码后,在没有 Git 元数据、没有桌面应用源码的目录中重新配置、构建 CLI 并运行 `doctor` 成功。
- 验证后恢复整合前的桌面 debug 构建配置,CLI 默认关闭;新命令行二进制保留在 `build/macosx/arm64/release/crossdesk-cli`。
## 优化回归(2026-10-30)
- Release 构建、实际二进制 bridge/PTY 回归通过。ASan/UBSan 检查覆盖原有会话和渲染行为,以及无效帧保留、NV12 缓冲复用、预览参数去重、ACK 状态、重复断开和重连网络统计复位。
- 生产 bridge 配合工程外假传输验证静止画面重绘、细节模式、质量切换、窗口变化、无效配置原子性和帧 ACK 背压,通过。
- ANSI 渲染前后对比覆盖 8/24/256 色、UTF-8/ASCII、half/quadrant、全景/细节模式,输出校验值一致。在本机 240×100 网格连续执行 200 次差分生成,静止网格约 447.4 ms → 3.6 ms,单行变化约 422.8 ms → 9.5 ms。该结果只衡量差分文本生成,不包含视频解码、图像缩放和终端显示时间。
- npm 真实打包、离线安装及源码包重建通过;工程外故障注入确认已有产物保留、第二个包生成失败时无半套输出、缺失脱敏补丁时拒绝打包。
新增回归代码、基准、假传输和验证产物均位于工程外临时目录,未向工程或子模块添加测试。此次完整构建与运行验证覆盖 macOS arm64,未重新进行 Linux/Windows 实机验证。
## 控制按键调整(2026-10-30)
- 当前桌面与 CLI 同时启用的 debug 配置下,`xmake build crossdesk-cli`、`xmake run crossdesk-cli doctor --json` 和命令帮助检查通过。
- 工程外 ASan/UBSan 检查覆盖本地命令前缀、只读快捷键、远端 Ctrl+T/Ctrl+C/Ctrl+]、鼠标释放、取消命令、粘贴隔离、UTF-8/CSI 分片、失焦与退出;原有会话/渲染检查通过。
- 平移字母键采用 `i` 上、`j` 左、`k` 下、`l` 右;工程外 ASan/UBSan 检查确认四个方向与普通/应用模式方向键一致,控制模式中普通字母仍传给远端。
- 细节/全景采用相邻的 `d`/`f`;工程外 ASan/UBSan 检查确认细节保留中心、全景复位缩放与中心、前缀命令和鼠标点击行为正确,控制模式中普通字母仍传给远端。
- 实际二进制 bridge 验证命令状态、错误恢复和退出响应;生产 view 配合假传输的 PTY 检查验证快捷键、模式提示、隐藏密码和终端恢复。实际二进制的 bridge 与 PTY 回归同样通过。
- Pi 1.4.0 SDK 严格类型检查和模拟组件运行通过,覆盖控制按键转发、命令提示、取消、缩放、退出与 bridge 启动失败后的关闭操作。
此次没有连接真实远端设备,也没有进行 Linux/Windows 或真实 Pi TUI 实机验证。新增测试代码与产物均保存在工程外系统临时目录。
## 直接 ID 入口与 npm 源码包(2026-10-30)
- 直接输入 9 位设备 ID 和 `view` 兼容别名均通过参数检查,保留前导零,支持相同的连接选项;无效 ID 在密码提示前拒绝。macOS arm64 终端中的密码提示与 Ctrl+C 取消通过。
- 本地 npm 包离线安装、启动器帮助和 `doctor --json` 通过;源码包包含完整的 `libs/controller/include`,补齐诊断头文件后,从安装包内源码独立配置、release 构建及 `doctor` 通过。
- 原有仓库结构、wire 和键盘状态测试通过;未新增或修改工程内测试,额外验证和产物均位于工程外临时目录。
- Build and Release #899 和 CLI native builds and npm packages #1 的首次运行均因 MiniRTC 提交 `b9ee1fa7` 尚未推送而在检出阶段失败。将该提交推送到 MiniRTC 远端后,全新递归浅克隆通过。
## 独立开发阶段的验证范围
以下为迁入前的验证记录摘要,不表示本次重新连接了远程设备:
- macOS arm64 的 debug/release 构建、CLI bridge 的 NDJSON、POSIX PTY 隐藏密码与终端恢复均已验证。
- 工程外的 ASan/UBSan 检查覆盖会话状态、只读限制、输入释放、帧内存所有权、渲染、缩放和平移、输出背压和帧 ACK。
- Pi 1.4.0 SDK 类型检查与模拟组件生命周期已验证;真实 Pi TUI 视觉效果未验收。
- 早期身份协议版本曾连接真实设备进行只读 ANSI 观看和 Kitty 协议数据验证;Kitty 数据解压检查不等于 Kitty 应用或 SSH 实机显示验收。
- 改为 `cli-` 临时身份后,正式 CLI 与本地 WSS 服务端验证了临时注册、Join、身份不可复用及退出清理;线上服务尚未在该轮部署,早期画面验证不能代替新身份协议的线上联调。
- npm 的 macOS arm64 本地生成及安装已验证;Linux / Windows 当时仅完成 CLI 主程序和平台 I/O 的交叉编译检查,没有完整 MiniRTC 链接和实机连接结果。
## 终端鼠标兼容修复(2026-10-30)
- 工程外假传输复现:旧解析器把传统 `CSI M` 报告的三个负载字节作为键盘文本发送,`mouse_reports` 仍为 0;SGR 点击只发送按下/松开,没有提前定位。
- 修复后的 ASan/UBSan 检查通过,覆盖 SGR/传统报告的全部二段分片与逐字节输入、左右中键、拖动、滚轮、点击前定位、高位坐标字节、黑边松开、只读/本地命令模式、失焦释放及发送失败。既有持续本地命令模式回归同样通过。
- macOS arm64 的 CLI debug 构建和 `doctor --json` 通过。生产 CLI 配合工程外假传输的 POSIX PTY 检查在 120/50 列窗口通过,验证逐字节鼠标输入、`Mouse RX`/`M:` 状态栏计数、日志统计、鼠标报告模式启停和原始终端模式恢复。
- 所有额外测试代码、脚本和产物均位于工程外临时目录。上述结果未覆盖真实 SSH 链路、GNOME Terminal 或 Linux 被控端实机,不能据此确认用户现场的问题已消失。
## 字符宽高比自动检测(2026-10-30)
- macOS arm64 CLI debug 构建通过。工程外 ASan/UBSan 检查覆盖字符/文本区尺寸报告、回复顺序与优先级、跨查询部分回复隔离、逐字节输入、本地命令/粘贴隔离、非法尺寸,以及 ANSI half/quadrant/ASCII 的画面比例与鼠标映射。
- 以 9×20 像素字符、120×40 字符视口和 1920×1080 源图模拟非 0.5 字符比例,旧默认值产生约 8%~9.3% 的宽高比误差;应用实际字符尺寸后为 1.25%,剩余为该网格的整数采样舍入。
- 生产 CLI 配合工程外假传输的 POSIX PTY 检查通过,覆盖 GNOME 风格的文本区像素/行列回复、直接字符尺寸、字体变化、窗口缩放、无回复/部分回复/非法回复、TTY 回退、手动覆盖、auto 和 Kitty。字体变化时没有新视频帧也能重绘,退出后终端模式恢复。
- 新增测试及产物均在工程外临时目录;尚未在真实 Linux GNOME Terminal 或 SSH 链路进行视觉验收。
## Windows 与 Pi/bridge 字符比例补全(2026-10-30)
- macOS arm64 CLI debug 构建通过;Windows x64 的主程序、平台 I/O、输入解析和尺寸解析通过交叉编译,平台 I/O 独立链接通过(含 user32/advapi32),不代表 Windows MiniRTC 完整构建或实机运行。
- 工程外 Win32 API 模拟检查直接使用生产尺寸读取函数,覆盖可见控制台字体尺寸、隐藏 ConPTY 回退、失败/非法字体尺寸与句柄释放,ASan/UBSan 通过。既有尺寸解析 ASan/UBSan 和 POSIX PTY 回归再次通过。
- 生产 bridge 配合工程外假传输验证可选比例字段保留、有限数值及范围检查、无效请求原子性、实际布局比例、静止画面重绘与帧比例元数据。
- Pi 1.4.0 SDK 严格类型检查及模拟宿主运行通过:使用真实 SDK 分片组包,覆盖文本区成对回复、字符尺寸优先级、宿主缓存、字体/窗口变化、手动覆盖、旧比例帧丢弃及 ACK、控制键回归、启动失败/异常退出后的监听与定时器清理。
- 新增验证代码和产物全部位于工程外系统临时目录;尚未在真实 Windows、GNOME/SSH 或 Pi TUI 进行视觉验收。
## ANSI 四象限接缝优化(2026-10-30)
- macOS arm64 的 CLI debug 构建和 `doctor --json` 通过。工程外 ASan/UBSan 检查覆盖低对比合并、横纵细线/斜线/双色图案、等亮度彩色边缘、随机 NV12 输入的失真上限、局部更新范围和重复转换确定性。
- 合成低对比图案中,3,072 个非空白字形全部合并为纯背景。合成渐变纹理的加权接缝误差由约 207,373 降至 180,187(13.1%);仅统计两端均未合并为纯色的边界时,由约 198,007 降至 169,621。指标衡量相邻样本的重建色差相对源图色差的偏离,并降低真实高对比边缘的权重;不代表实机条纹减少的百分比。
- 8/24/256 色输出、half/ASCII 与原实现一致性、不同视口/比例/缩放的布局和鼠标映射、静止网格差分及 Kitty 输出检查通过。
- 本机 `-O2`、1920×1080 输入、240×100 字符视口、每项 12 次转换的合成基准:平滑画面约 7.14 → 6.03 ms/帧,随机亮度噪声约 5.44 → 7.73 ms/帧。仅衡量转换,不包含视频解码、ANSI 输出和终端绘制。
- 额外检查代码、基准和数据均位于工程外系统临时目录;未新增工程内测试,未连接真实远端或进行终端字形/行距的视觉验收。
## ANSI 转换与输出加速(2026-10-30)
- macOS arm64 CLI debug 构建和 `doctor --json` 通过。工程外 ASan/UBSan 与 `-O2` 检查均通过:加速后的转换与此前接缝优化版本逐格颜色/字形一致,覆盖随机 NV12、低对比及平滑纹理、half/quadrant/ASCII、缩放和细节模式;Kitty 数据保持一致。
- ANSI 转义序列模拟器验证最终屏幕一致,覆盖全部四象限字形、8/24/256 色、纯背景及色板复用、首尾单元、局部修改、视口/偏移变化和可独立绘制的 bridge 字符行。未向工程新增测试文件或用例。
- 本机 `-O2`、1920×1080 输入、240×100 字符视口、每项 24 次转换:平滑画面约 6.26 → 4.83 ms/帧,随机亮度噪声约 7.75 → 5.72 ms/帧。保留面积滤波、低对比合并及边界连续性约束。
- 240×100 真彩色合成网格、每项 100 次输出生成:整帧约 2.03 → 1.43 ms;120×50 单元的局部修改约 1.02 → 0.36 ms,输出 461,743 → 231,026 字节;2×3 单元的光标修改约 0.072 → 0.013 ms,输出 27,763 → 262 字节。纯背景色块整帧输出 875,352 → 449,826 字节。
- 这些结果衡量转换、编码及输出量,不包含实际终端绘制或网络传输。整帧高色彩变化图案仍需约 923 KB,仍受 1 MiB/s 输出预算限制;不能将转换耗时的倒数作为远程显示帧率。当时默认本地目标为 8 FPS,后续默认策略见“默认 30 FPS 与供帧自适应”。
- 额外检查代码、模拟器、基准和产物均位于工程外临时目录;未进行跨平台终端或真实远端帧率验收。
## Kitty 帧间黑屏与本地吞吐修复(2026-10-30)
- macOS arm64 debug、release 构建通过,release `doctor --json` 通过。release 使用工程外独立配置构建,保留工程原有 debug 配置;额外检查脚本、伪终端夹具和数据均位于系统临时目录。
- ASan/UBSan 检查及 Kitty 协议模拟验证:ID 31/32 交替上传,所有上传分块完成前旧图保持显示;先创建新图 placement,再释放旧图;最多两个完整图像驻留,切换后只保留一个。新旧输出的解压 RGB 和尺寸逐字节一致,覆盖尺寸、偏移和缩放变化;单帧小于 2MiB,全部分块边界的中断清理通过。
- ANSI 的 half/quadrant/ASCII 和 8/24/256 色共 12 种组合,转换单元及输出字节与此次修改前一致。
- 使用生产 `View`、`Output` 和 Kitty 编码代码、`-O2`、合成 NV12 持续回调的本地 PTY 对比:目标 8 FPS,单帧约 1.25 MB,稳定完整接收速率约 0.84 → 6.71 FPS,帧间隔中位数约 1,192 → 148 ms。只衡量完整输出接收,不包含真实网络、视频解码和 Ghostty GPU 显示,不能作为实机帧率承诺。
- 本地 Kitty 的日志预算为 8MiB/s;分别仅设置 `SSH_TTY`、`SSH_CONNECTION`、`SSH_CLIENT` 时均保持 1MiB/s,ANSI 也保持 1MiB/s。连续帧 ID 仅在完整写入后交替,退出清理两个 ID。
- 慢速 PTY 在第二帧上传中退出:首帧保留、第二帧未提交,约 2.06s 后退出;两个 ID 清理、备用屏幕/光标控制序列和原 termios 均恢复。测试发现原单次清理写入会在满缓冲区时丢失,现增加至多 500ms 的非阻塞短写重试。
- 模拟父 shell 保持会话、终端完全停止读取的 PTY:CLI 约 2.53s 后返回,termios 和非阻塞标志恢复。POSIX 恢复改为 `TCSANOW` 加输入清理,避免 `TCSAFLUSH` 等待输出排空。完全不读取时,屏幕清理字节不保证送达。
- 本机安装 Ghostty 1.4.0,但自动化工具拒绝访问其窗口,未完成 Ghostty 实窗视觉验收。npm 安装的 CLI 不会随本地构建自动更新,复测需要直接运行本次 release 产物。
## 本地 Kitty 共享内存与 30 FPS 目标(2026-10-30)
- 本地 POSIX Kitty 增加共享内存能力探测、唯一名称、0600 权限、上传确认和 500ms 超时回退。单个待确认共享缓冲区不超过 1920×1080×3 字节;确认前保留旧画面,退出释放未确认对象。Windows 暂保留内联路径,SSH 不发共享内存探测。
- 当时共享内存可用且未显式指定 `--fps` 时默认目标 30;自动回退内联恢复 8,显式帧率保持不变。后续默认策略见“默认 30 FPS 与供帧自适应”。目标大于 15 使用现有预览协议的交互模式 `fps=60, frame_rate=30`;没有新增协议取值或修改被控端。
- 工程外 ASan/UBSan 检查通过:110 组随机帧/视口/宽高比/缩放/细节模式,整数面积滤波相对原浮点参考的每通道最大误差为 1/255;内联 Kitty 和 ANSI 保持原输出。覆盖共享内存权限、读取内容、命名、容量限制、双方 unlink、分片确认及非法/溢出/重复 ID 回应。
- 使用实际 release CLI 对象代码、1920×1080 合成噪声 NV12 连续回调、1568×1088 报告视口的 PTY 测试:内联完整接收约 4.06 FPS,共享内存约 29.04 FPS;平均本地处理约 166.6 → 12.3 ms,终端字节流约 1,703,174 → 188 字节/帧。共享内存仍传输完整 RGB,并非总数据量只有 188 字节;数据复制、终端绘制和实际网络另有成本。
- 显式 8 FPS、探测不支持/静默、第三帧上传报错/丢失确认、80ms 延迟确认、SSH 禁用探测均通过。回退前不显示未确认帧,运行时回退仅触发一次,并正确恢复自动目标和协议请求;所有共享对象均清理。第二帧上传后立即退出时,首帧仍是已提交画面,未确认对象及两个图像 ID 均清理。
- 相同合成输入的 debug 构建约 5.91 FPS,本地处理约 163 ms,说明 `-O0` 仍明显影响像素转换;性能验收使用 release。上述精确数字是本地测试接收速率,不是 Ghostty GPU 显示帧率。
- 用户在本机 Ghostty 运行新版 release 后反馈“明显流畅了”。额外测试源码、数据、构建夹具和产物均位于系统临时目录,未在工程或子模块新增测试。
- 该次实机日志确认 `kitty_transport=shared_memory`、目标 30、预览请求已接受、运行时回退 0 次。会话约 13.61s 完整提交 273 帧,平均提交约 20.06 FPS(包含约 1.49s 首帧启动等待);平均本地处理约 10.87ms、共享内存确认约 0.42ms。该日志记录提交速率,不测量终端 GPU 呈现频率。
## 默认 30 FPS 与供帧自适应(2026-10-30)
- 交互 CLI 默认本地目标统一为 30 FPS,Kitty 共享内存回退不再改写该目标;远端请求独立在 8/15/30 FPS 档位间自适应,15 需要对端能力。显式 `--fps` 保留固定请求,bridge 默认仍为 8。
- macOS arm64 debug/release 构建、release `doctor --json` 和帮助输出检查通过。工程外 ASan/UBSan 验证了升降档阈值、迟滞、恢复、15 FPS 能力缺失、显式帧率、源帧稀疏、请求拒绝以及估计重置。
- 使用真实 release CLI 对象代码、支持预览确认和动态供帧的假传输、1920×1080 合成 NV12 与 1568×1088 像素视口进行 PTY 验证。固定 30 FPS 源的共享内存路径完整接收约 30 FPS,未触发自动降档;不再在没有新帧时提前消耗刷新时隙。
- 高熵内联路径实际约 4 FPS 时,自动请求由 30 降为 8,本地目标保持 30;显式 `--fps 30` 在同条件下仍约 4 FPS,远端请求不变。上传报错/超时和探测静默均保持本地 30 目标,随后按完整帧耗时调节供帧。
- 90ms 上传确认延迟使实际接收约 9 FPS,远端请求自动调整为 15;缺少 15 FPS 能力时不发送该档位。200ms 延迟后恢复正常确认,验证远端请求按 30 → 8 → 30 恢复。源仅提供 2 FPS、对端拒绝预览时均未误触发自动降档。
- SSH 环境保持原有 1MiB/s 字节预算且不探测共享内存;慢速输出降远端请求但保留本地目标。ANSI 默认 30/显式 15 和 bridge 默认 8/帧 ACK 通过。所有 Kitty 场景继续验证不显示未确认帧、最多两个终端图像、共享对象释放及退出后终端模式恢复。
- 以上数字是合成源和伪终端的完整输出接收率,不包含真实视频解码、网络或 Ghostty GPU 呈现。全部额外测试代码、数据、构建脚本和产物位于工程外系统临时目录;本次未新增工程内测试,尚未对新版进行 Ghostty 实窗验收。
## 实测边界
本地检查不涵盖 Linux / Windows 完整构建、跨平台远程控制、Kitty/SSH 实际终端显示、真实 Pi TUI 或长时间资源占用。五个平台 CI 配置与 npm 包名均不代表已发布或已验收。
CLI 需要支持 `cli` 临时注册的服务端。`doctor --json` 仅报告本机环境,不会联网登录或证明远端可连接。
+1187
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { spawn } = require('node:child_process');
const platforms = require('./platforms.json');
const { name: launcherPackage, version } = require('../package.json');
function main() {
const target = `${process.platform}-${process.arch}`;
const platform = platforms[target];
if (!platform) {
throw new Error(`No crossdesk-cli build for ${target}. Supported targets: ${Object.keys(platforms).join(', ')}.`);
}
if (process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime) {
throw new Error('This release requires glibc Linux. Alpine/musl needs a separate native build.');
}
const packageName = platform.packageName;
let manifestPath;
try {
manifestPath = require.resolve(`${packageName}/package.json`);
} catch {
throw new Error(`Missing ${packageName}@${version}. Install with optional dependencies enabled; this platform's package must be published before ${launcherPackage}.`);
}
if (require(manifestPath).version !== version) {
throw new Error(`Expected ${packageName}@${version}; reinstall matching package versions.`);
}
const binary = path.join(path.dirname(manifestPath), 'bin', process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli');
fs.accessSync(binary, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
const args = process.argv.slice(2);
// Hosts such as Pi should spawn the native path directly, retaining descriptor
// inheritance and control of the process lifetime without an npm intermediary.
if (args.length === 1 && args[0] === '--print-binary-path') {
process.stdout.write(`${binary}\n`);
return;
}
const stdio = ['inherit', 'inherit', 'inherit'];
if (args[0] === 'bridge') {
const index = args.lastIndexOf('--password-fd');
const fd = index === -1 ? 3 : Number(args[index + 1]);
if (Number.isInteger(fd) && fd >= 3 && fd <= 1024) {
try {
const stat = fs.fstatSync(fd);
if (stat.isFIFO() || stat.isSocket() || stat.isFile()) {
while (stdio.length <= fd) stdio.push('ignore');
stdio[fd] = fd;
}
} catch { /* The native bridge reports a missing credential descriptor. */ }
}
}
const child = spawn(binary, args, { stdio, shell: false });
const handlers = new Map();
if (process.platform !== 'win32') {
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
const handler = () => { if (child.exitCode === null) child.kill(signal); };
handlers.set(signal, handler);
process.on(signal, handler);
}
} else {
// Console Ctrl+C is delivered to both processes. Do not terminate the native
// child before it has restored console modes.
const handler = () => {};
handlers.set('SIGINT', handler);
process.on('SIGINT', handler);
}
const cleanup = () => {
for (const [signal, handler] of handlers) process.removeListener(signal, handler);
};
child.once('error', (error) => {
cleanup();
process.stderr.write(`crossdesk-cli: cannot start native program (${error.code || 'unknown error'}).\n`);
process.exitCode = 1;
});
child.once('exit', (code, signal) => {
cleanup();
if (signal && process.platform !== 'win32') process.kill(process.pid, signal);
else process.exitCode = code ?? 1;
});
}
try {
main();
} catch (error) {
process.stderr.write(`crossdesk-cli: ${error.message}\n`);
process.exitCode = 1;
}
+7
View File
@@ -0,0 +1,7 @@
{
"darwin-arm64": { "packageName": "@chunkdash/crossdesk-cli-darwin-arm64", "os": "darwin", "cpu": "arm64", "xmakePlatform": "macosx", "xmakeArch": "arm64" },
"darwin-x64": { "packageName": "@chunkdash/crossdesk-cli-darwin-x64", "os": "darwin", "cpu": "x64", "xmakePlatform": "macosx", "xmakeArch": "x86_64" },
"linux-arm64": { "packageName": "@chunkdash/crossdesk-cli-linux-arm64", "os": "linux", "cpu": "arm64", "libc": "glibc", "xmakePlatform": "linux", "xmakeArch": "arm64" },
"linux-x64": { "packageName": "@chunkdash/crossdesk-cli-linux-x64", "os": "linux", "cpu": "x64", "libc": "glibc", "xmakePlatform": "linux", "xmakeArch": "x86_64" },
"win32-x64": { "packageName": "@chunkdash/crossdesk-cli-windows-x64", "os": "win32", "cpu": "x64", "xmakePlatform": "windows", "xmakeArch": "x64" }
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@chunkdash/crossdesk-cli",
"version": "0.1.2",
"private": true,
"description": "CrossDesk remote desktop controller for the terminal",
"license": "GPL-3.0-only",
"author": "DI JUNKUN",
"repository": {
"type": "git",
"url": "git+https://github.com/kunkundi/crossdesk.git",
"directory": "apps/cli"
},
"homepage": "https://github.com/kunkundi/crossdesk#readme",
"bugs": { "url": "https://github.com/kunkundi/crossdesk/issues" },
"engines": { "node": ">=22" },
"scripts": {
"pack:npm": "node scripts/pack-npm.cjs"
}
}
+250
View File
@@ -0,0 +1,250 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { truncateToWidth, parseKey, decodeKittyPrintable, isKeyRelease, getCellDimensions } from "@earendil-works/pi-tui";
import { spawn, type ChildProcess } from "node:child_process";
import type { Writable } from "node:stream";
const clean = (s: unknown) => String(s ?? "").replace(/[^\x20-\x7e]/g, "?").slice(0, 180);
const validAspect = (n: number) => Number.isFinite(n) && n >= 0.2 && n <= 2;
function aspectOf(width: number, height: number): number | undefined {
const ratio = width / height;
return width >= 1 && width <= 1024 && height >= 1 && height <= 1024 && validAspect(ratio) ? ratio : undefined;
}
// Normalize enhanced key reports with the host's actual keyboard decoder.
function legacyInput(data: string): string {
if (isKeyRelease(data)) return "";
const key = parseKey(data);
if (key === "ctrl+]") return "\x1d";
if (key?.match(/^ctrl\+[a-z]$/)) return String.fromCharCode(key.charCodeAt(5) - 96);
if (key?.match(/^alt\+[a-z]$/)) return "\x1b" + key[4];
const keys: Record<string, string> = { enter: "\r", escape: "\x1b", backspace: "\x7f", tab: "\t",
up: "\x1b[A", down: "\x1b[B", right: "\x1b[C", left: "\x1b[D", home: "\x1b[H", end: "\x1b[F",
delete: "\x1b[3~", pageUp: "\x1b[5~", pageDown: "\x1b[6~" };
return (key && keys[key]) || decodeKittyPrintable(data) || data;
}
export default function crossdesk(pi: ExtensionAPI) {
let stop: (() => void) | undefined;
pi.on("session_shutdown", async () => stop?.());
pi.registerCommand("crossdesk", {
description: "Open a human-only remote desktop view (Ctrl+] for commands)",
handler: async (args, ctx) => {
if (ctx.mode !== "tui" || !ctx.hasUI || !process.stdin.isTTY || !process.stdout.isTTY) {
ctx.ui.notify("CrossDesk embedded view requires Pi interactive TUI. Run crossdesk-cli <device-id> in a terminal.", "warning");
return;
}
if (stop) { ctx.ui.notify("A CrossDesk view is already open", "warning"); return; }
const configuredAspect = process.env.CROSSDESK_CELL_ASPECT?.trim();
const manualAspect = !configuredAspect || configuredAspect === "auto" ? undefined : Number(configuredAspect);
if (manualAspect !== undefined && !validAspect(manualAspect)) {
ctx.ui.notify("CROSSDESK_CELL_ASPECT must be auto or a width/height ratio from 0.2 to 2", "warning");
return;
}
const device = (args.trim() || await ctx.ui.input("CrossDesk device ID", "9 digits"))?.trim();
if (!device) return;
if (!/^\d{9}$/.test(device)) { ctx.ui.notify("Expected a 9-digit device ID", "error"); return; }
// A custom masked prompt keeps credentials out of the editor, history,
// extension messages, model tools and package configuration.
let password = await ctx.ui.custom<string | undefined>((tui, _theme, _keys, done) => {
let secret = "";
return {
render: (width: number) => [truncateToWidth("Remote password (Enter connects, Esc cancels): " + "*".repeat([...secret].length), width)],
invalidate() {},
handleInput(data: string) {
data = legacyInput(data);
if (!data) return;
if (data === "\x1b" || data === "\x1d") { secret = ""; done(undefined); }
else if (data === "\r" || data === "\n") { const value = secret; secret = ""; done(value); }
else if (data === "\x7f" || data === "\b") secret = [...secret].slice(0, -1).join("");
else {
const text = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
if (!/[\x00-\x1f\x7f-\x9f]/.test(text) && Buffer.byteLength(secret + text) <= 256) secret += text;
}
tui.requestRender();
},
};
});
if (password === undefined) return;
let child: ChildProcess | undefined;
let killTimer: ReturnType<typeof setTimeout> | undefined;
let stopGeometry: (() => void) | undefined;
const cleanup = () => {
stopGeometry?.(); stopGeometry = undefined;
if (!child) return;
const owned = child; child = undefined;
owned.stdin?.end(); // EOF asks the bridge to disconnect and release input.
killTimer = setTimeout(() => {
owned.kill("SIGTERM");
const force = setTimeout(() => owned.kill("SIGKILL"), 2000); force.unref();
owned.once("exit", () => clearTimeout(force));
}, 2000);
killTimer.unref();
owned.once("exit", () => { if (killTimer) clearTimeout(killTimer); });
};
try {
await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
let closed = false, status = "starting", error = "", lines: string[] = [], buffer = "";
let columns = 0, rows = 0, request = 0, ready = false, generation = 0;
let frame: { frame_id: number; generation: number } | undefined;
let helloId = 0, zoom = 1, native = false;
let control = false, commandMode = false, bridgeStopped = false;
const hostCell = getCellDimensions();
let cellAspect = manualAspect ?? aspectOf(hostCell.widthPx, hostCell.heightPx) ?? 0.5;
const colorHint = process.env.COLORTERM || "";
const colors = /^(truecolor|24bit)$/.test(colorHint) ? 24 : process.env.TERM?.includes("256color") ? 256 : 8;
const finish = () => { if (closed) return; closed = true; cleanup(); stop = undefined; lines = []; done(); };
stop = finish;
const fail = (reason: string) => { bridgeStopped = true; error = clean(reason); cleanup(); tui.requestRender(); };
child = spawn(process.env.CROSSDESK_CLI || "crossdesk-cli", ["bridge", "--stdio", "--password-fd", "3"], {
shell: false, stdio: ["pipe", "pipe", "pipe", "pipe"],
});
const processChild = child;
const send = (method: string, fields: Record<string, unknown> = {}) => {
if (closed || !child?.stdin || child.stdin.destroyed) return 0;
if (child.stdin.writableLength > 65536) { fail("Bridge is not consuming requests"); return 0; }
const id = ++request;
child.stdin.write(JSON.stringify({ id, method, ...fields }) + "\n"); return id;
};
const sendViewport = () => send("viewport", {
columns: columns || 80, rows: rows || 20, cell_aspect: cellAspect,
colors, utf8: true, fps: 8, detail: "quadrant", quality: "high",
});
child.stdin?.on("error", () => fail("Bridge input closed"));
const secrets = child.stdio[3] as Writable;
secrets.on("error", () => fail("Credential channel closed"));
secrets.end(password + "\n"); password = undefined;
child.stderr?.resume(); // Never insert native diagnostics in the TUI or chat.
child.on("error", () => fail("Cannot start crossdesk-cli; check CROSSDESK_CLI or PATH"));
child.on("exit", (code) => {
if (child === processChild) child = undefined;
stopGeometry?.(); stopGeometry = undefined;
if (!closed) { bridgeStopped = true; error ||= `Bridge exited (${code ?? "signal"})`; tui.requestRender(); }
});
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (data: string) => {
if (closed) return;
buffer += data;
if (buffer.length > 2 * 1024 * 1024) { fail("Bridge message exceeds 2 MiB"); return; }
for (;;) {
const end = buffer.indexOf("\n"); if (end < 0) break;
const raw = buffer.slice(0, end); buffer = buffer.slice(end + 1);
try {
const message = JSON.parse(raw);
if (message.id === helloId && message.ok && message.version === 1 && !ready) {
ready = true;
sendViewport();
send("connect", { device });
}
if (message.ok === false) error = clean(message.error);
if (message.status) {
if (message.status.exit_requested) { clearTimeout(watchdog); finish(); return; }
if (Number.isSafeInteger(message.status.generation)) generation = message.status.generation;
control = message.status.control === true;
commandMode = message.status.command_mode === true;
status = clean(message.status.state) + (commandMode
? " LOCAL COMMAND / " + (message.status.resume_control === true ? "CONTROL PAUSED" : "READ ONLY")
: control ? " CONTROL" : " READ ONLY");
if (message.status.error) error = clean(message.status.error);
if (message.status.state !== "connected") lines = [];
}
if (message.event === "frame") {
if (!Number.isSafeInteger(message.frame_id) || !Number.isSafeInteger(message.generation) ||
!Array.isArray(message.lines) || message.lines.length > 100)
throw new Error("Invalid frame");
if (message.columns !== columns || message.rows !== rows || message.generation < generation ||
(message.cell_aspect !== undefined && message.cell_aspect !== cellAspect)) {
send("ack", {frame_id: message.frame_id, generation: message.generation});
continue; // A frame already in flight may precede the resize response.
}
for (const line of message.lines) {
// Only our SGR colors, spaces and block glyphs may reach Pi.
if (typeof line !== "string" || line.length > 20000 || /[^ \u2580-\u259f]/u.test(line.replace(/\x1b\[[0-9;]*m/g, "")))
throw new Error("Invalid terminal line");
}
if (Number.isFinite(message.zoom) && message.zoom >= 1 && message.zoom <= 32) zoom = message.zoom;
native = message.native === true;
lines = message.lines; frame = { frame_id: message.frame_id, generation: message.generation };
}
tui.requestRender();
} catch { fail("Invalid bridge protocol message"); }
}
});
helloId = send("hello", { version: 1 });
const watchdog = setTimeout(() => { if (!ready) fail("Bridge handshake timeout"); }, 5000);
processChild.once("exit", () => clearTimeout(watchdog));
// Use the host's terminal writer and sequence listener; Pi remains
// the sole owner of raw input and screen rendering.
let pixelSize: [number, number] | undefined, cellCount: [number, number] | undefined;
let directCell = false, queried = false;
const updateAspect = (width: number, height: number) => {
const ratio = aspectOf(width, height);
if (manualAspect !== undefined || ratio === undefined || ratio === cellAspect) return;
cellAspect = ratio; lines = []; frame = undefined;
if (ready) sendViewport();
tui.requestRender();
};
const removeGeometryListener = tui.addInputListener(data => {
// Pi's StdinBuffer assembles split replies before listeners run.
const match = /^\x1b\[([468]);([0-9]+);([0-9]+)t$/.exec(data);
if (!queried || !match) return;
const type = Number(match[1]), height = Number(match[2]), width = Number(match[3]);
if (![height, width].every(n => Number.isSafeInteger(n) && n > 0 && n <= 1048576))
return { consume: true };
if (type === 6) {
if (aspectOf(width, height) === undefined) return { consume: true };
directCell = true;
updateAspect(width, height);
return; // Let Pi update its own shared cell-size cache as well.
}
if (type === 4) pixelSize = [width, height];
if (type === 8) cellCount = [width, height];
if (!directCell && pixelSize && cellCount)
updateAspect(pixelSize[0] / cellCount[0], pixelSize[1] / cellCount[1]);
return { consume: true };
});
let terminalColumns = tui.terminal.columns, terminalRows = tui.terminal.rows;
const queryGeometry = () => {
if (closed || bridgeStopped) return;
queried = true; directCell = false; pixelSize = cellCount = undefined;
tui.terminal.write("\x1b[16t\x1b[14t\x1b[18t");
};
const geometryTimer = setInterval(queryGeometry, 2000);
geometryTimer.unref();
stopGeometry = () => { clearInterval(geometryTimer); removeGeometryListener(); };
queryGeometry();
return {
render(width: number) {
const w = Math.max(1, Math.min(240, width - 1));
const h = Math.max(1, Math.min(100, tui.terminal.rows - 5));
if (w !== columns || h !== rows) {
columns = w; rows = h; lines = []; frame = undefined;
if (ready) sendViewport();
}
if (terminalColumns !== tui.terminal.columns || terminalRows !== tui.terminal.rows) {
terminalColumns = tui.terminal.columns; terminalRows = tui.terminal.rows;
queryGeometry();
}
const ack = frame; frame = undefined;
if (ack) queueMicrotask(() => send("ack", ack)); // Host consumed the frame; not a physical display timestamp.
const hint = bridgeStopped ? "q / Esc / Ctrl+C close"
: commandMode ? "Esc/Ctrl+C return | c toggle q quit | arrows/ijkl pan +/- zoom f fit d detail"
: control ? "Keys -> remote | Ctrl+] then c release, q quit"
: "c control q quit ? help | i up j left k down l right | +/- zoom f fit d detail";
return [truncateToWidth(`CrossDesk ${device} | ${status} ${native ? "detail" : "fit"} x${zoom} | Ctrl+] commands`, width),
...lines.slice(0, h).map(line => truncateToWidth(line, width)),
truncateToWidth(`${hint}${error ? " | " + error : lines.length ? "" : " | Waiting for remote video…"}`, width)];
},
handleInput(data: string) {
data = legacyInput(data);
if (!data) return;
if ((bridgeStopped || !ready) && (data === "q" || data === "\x1b" || data === "\x03")) { clearTimeout(watchdog); finish(); }
else if (ready && Buffer.byteLength(data) <= 16384) send("input", { data });
},
invalidate() {},
dispose() { clearTimeout(watchdog); cleanup(); },
};
});
} finally { password = undefined; cleanup(); stop = undefined; }
},
});
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "crossdesk-pi",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Human-only CrossDesk terminal view for Pi",
"keywords": ["pi-package"],
"pi": { "extensions": ["./index.ts"] },
"peerDependencies": {
"@earendil-works/pi-coding-agent": "^1.4.0",
"@earendil-works/pi-tui": "^1.4.0"
}
}
+202
View File
@@ -0,0 +1,202 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const cliRoot = path.resolve(__dirname, '..');
const root = path.resolve(cliRoot, '../..');
const project = require('../package.json');
const platforms = require('../npm/platforms.json');
function run(command, args, options = {}) {
return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...options });
}
function copy(from, to) {
fs.cpSync(path.join(root, from), to, {
recursive: true,
filter: (entry) => !['.git', '.xmake', '.vscode', 'build', 'out', 'node_modules', 'tests', 'test'].includes(path.basename(entry)),
});
}
function json(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
function tarballName(name, version) {
// npm pack writes @scope/name as scope-name-version.tgz.
return `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz`;
}
function inspect(binary, target) {
const data = fs.readFileSync(binary);
const info = { target, sha256: crypto.createHash('sha256').update(data).digest('hex') };
if (process.platform === 'darwin') {
const cpu = process.arch === 'arm64' ? 0x100000c : 0x1000007;
if (data.length < 32 || data.readUInt32LE(0) !== 0xfeedfacf ||
data.readUInt32LE(4) !== cpu || data.readUInt32LE(12) !== 2) {
throw new Error('Expected a Mach-O executable matching the current CPU.');
}
info.dependencies = run('otool', ['-L', binary]).split('\n').slice(1)
.map((line) => line.trim().split(' (')[0]).filter(Boolean);
for (const dependency of info.dependencies) {
if (!dependency.startsWith('/usr/lib/') && !dependency.startsWith('/System/Library/'))
throw new Error(`Non-system macOS library must be bundled before packaging: ${dependency}`);
}
const commands = run('otool', ['-l', binary]);
info.minimumOS = commands.match(/\bminos\s+([\d.]+)/)?.[1] ||
commands.match(/LC_VERSION_MIN_MACOSX[\s\S]*?\bversion\s+([\d.]+)/)?.[1];
if (!info.minimumOS) throw new Error('Cannot determine macOS deployment target.');
run('codesign', ['--verify', binary]);
} else if (process.platform === 'linux') {
const machine = process.arch === 'arm64' ? 183 : 62;
if (data.length < 64 || data.subarray(0, 4).toString('hex') !== '7f454c46' ||
data[4] !== 2 || data[5] !== 1 || data.readUInt16LE(18) !== machine)
throw new Error('Expected a 64-bit ELF executable matching the current CPU.');
if (!process.report.getReport().header.glibcVersionRuntime)
throw new Error('The Linux npm packages currently target glibc, not musl.');
info.dependencies = run('ldd', [binary]).trim().split('\n').map((line) => line.trim());
const system = /^(linux-vdso|ld-linux|lib(c|m|dl|rt|pthread|gcc_s|stdc\+\+|z|resolv)\.)/;
for (const dependency of info.dependencies) {
const name = path.basename(dependency.split(/\s/)[0]);
if (dependency.includes('not found') || !system.test(name))
throw new Error(`Unbundled Linux dependency: ${dependency}`);
}
const symbols = run('objdump', ['-T', binary]);
info.glibc = [...symbols.matchAll(/\bGLIBC_([\d.]+)/g)].map((match) => match[1])
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1);
info.glibcxx = [...symbols.matchAll(/\bGLIBCXX_([\d.]+)/g)].map((match) => match[1])
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1);
} else {
const offset = data.length >= 64 ? data.readUInt32LE(60) : data.length;
const machine = 0x8664;
if (data.toString('ascii', 0, 2) !== 'MZ' || offset + 6 > data.length ||
data.readUInt32LE(offset) !== 0x4550 || data.readUInt16LE(offset + 4) !== machine)
throw new Error('Expected a Windows PE executable matching the current CPU.');
info.dependencies = [...run('dumpbin.exe', ['/DEPENDENTS', binary])
.matchAll(/^\s+([\w.-]+\.dll)\s*$/gim)].map((match) => match[1]);
if (!info.dependencies.length) throw new Error('Cannot inspect Windows DLL dependencies.');
for (const dependency of info.dependencies) {
if (/^(msvcp|vcruntime|concrt)/i.test(dependency) ||
(!/^(api-ms-|ext-ms-)/i.test(dependency) &&
!fs.existsSync(path.join(process.env.SystemRoot, 'System32', dependency))))
throw new Error(`Unbundled Windows runtime: ${dependency}. Build with static runtimes (MT).`);
}
info.minimumOS = 'Windows 10 (VT console)';
}
const doctor = JSON.parse(run(binary, ['doctor', '--json'], { timeout: 15000 }));
if (doctor.bridge_version !== 1) throw new Error('Native binary does not report the expected bridge version.');
return info;
}
function pack(directory, output) {
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
const expected = tarballName(manifest.name, manifest.version);
if (fs.existsSync(path.join(output, expected))) throw new Error(`Output already exists: ${expected}`);
const args = ['pack', '--json', '--ignore-scripts', '--pack-destination', output];
if (process.platform === 'win32' && !process.env.npm_execpath)
throw new Error('On Windows, run this script through npm run pack:npm.');
const result = process.env.npm_execpath
? run(process.execPath, [process.env.npm_execpath, ...args], { cwd: directory })
: run('npm', args, { cwd: directory });
// npm 12 uses a name-keyed object instead of the earlier array format.
const parsed = JSON.parse(result);
const entries = Array.isArray(parsed) ? parsed : Object.values(parsed);
if (entries.length !== 1) throw new Error('npm pack must produce exactly one package.');
const [{ filename, size }] = entries;
if (filename !== expected) throw new Error(`Unexpected npm tarball name: ${filename}; expected ${expected}`);
return { filename, size };
}
let staging;
const created = [];
try {
const args = process.argv.slice(2);
let output = path.join(root, 'build', 'npm');
let binary;
for (let i = 0; i < args.length; i += 2) {
if (!args[i + 1]) throw new Error(`Missing value for ${args[i]}`);
if (args[i] === '--out-dir') output = path.resolve(args[i + 1]);
else if (args[i] === '--binary') binary = path.resolve(args[i + 1]);
else throw new Error(`Unknown option: ${args[i]}`);
}
const target = `${process.platform}-${process.arch}`;
const platform = platforms[target];
if (!platform) throw new Error(`No packaging target for ${target}`);
const packageNames = Object.values(platforms).map(({ packageName }) => packageName);
if (packageNames.some((name) => typeof name !== 'string' || name.length > 214 ||
!/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(name)))
throw new Error('Every npm platform must declare a valid scoped packageName.');
if (new Set(packageNames).size !== packageNames.length)
throw new Error('Each npm platform must have a distinct packageName.');
const version = fs.readFileSync(path.join(cliRoot, 'xmake.lua'), 'utf8').match(/set_version\("([^"]+)"\)/)?.[1];
if (version !== project.version) throw new Error('apps/cli/package.json and apps/cli/xmake.lua versions must match.');
for (const name of [project.name, platform.packageName]) {
const filename = tarballName(name, version);
if (fs.existsSync(path.join(output, filename))) throw new Error(`Output already exists: ${filename}`);
}
run('git', ['-C', path.join(root, 'deps/submodules/minirtc'), 'apply', '--reverse', '--check',
path.join(root, 'deps/patches/minirtc-credential-logs.patch')]);
const executable = process.platform === 'win32' ? 'crossdesk-cli.exe' : 'crossdesk-cli';
binary ||= path.join(root, 'build', platform.xmakePlatform, platform.xmakeArch, 'release', executable);
const info = inspect(binary, target);
staging = fs.mkdtempSync(path.join(os.tmpdir(), 'crossdesk-npm-'));
fs.mkdirSync(output, { recursive: true });
const native = path.join(staging, 'native');
fs.mkdirSync(path.join(native, 'bin'), { recursive: true });
fs.copyFileSync(binary, path.join(native, 'bin', executable));
fs.chmodSync(path.join(native, 'bin', executable), 0o755);
copy('LICENSE', path.join(native, 'LICENSE'));
// Include the controller and patched MiniRTC sources used by this checkout.
// External Xmake dependency source/licenses still belong to the release's
// dependency bundle; see source/apps/cli/docs/source-provenance.md.
// Keep desktop build definitions for Xmake's initial option discovery;
// desktop application sources and resources are intentionally not bundled.
for (const entry of ['apps/cli', 'apps/desktop/xmake',
'libs/controller/include',
'libs/controller/src', 'libs/terminal', 'libs/wire/include',
'libs/wire/src', 'deps/submodules/minirtc',
'deps/submodules/xmake.lua', 'deps/thirdparty', 'deps/patches', 'xmake.lua',
'LICENSE', '.gitmodules'])
copy(entry, path.join(native, 'source', entry));
json(path.join(native, 'build-info.json'), { version, ...info });
fs.writeFileSync(path.join(native, 'README.md'), `# ${platform.packageName}\n\nNative ${target} dependency for [${project.name}](https://www.npmjs.com/package/${project.name}) ${version}, published under the ${platform.packageName.split('/')[0]} scope.\n\nInstall the cross-platform entry package with \`npm install -g ${project.name}\` or run \`npx ${project.name} doctor --json\`. This platform package contains the native binary and does not register a shell command.\n\nSource repository: [CrossDesk](${project.repository.url.replace(/^git\+/, '').replace(/\.git$/, '')}). Report problems at ${project.bugs.url}.\n\nRuntime requirements and linked libraries: [build-info.json](build-info.json).\n\nController and patched MiniRTC sources are included in source/. From source/, configure with xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release, then run xmake build crossdesk-cli. External dependency notices and corresponding sources must accompany the product release; see source/apps/cli/docs/source-provenance.md.\n`);
const metadata = {
license: project.license, author: project.author, repository: project.repository,
homepage: project.homepage, bugs: project.bugs,
publishConfig: { access: 'public', registry: 'https://registry.npmjs.org/' },
};
json(path.join(native, 'package.json'), {
...metadata, name: platform.packageName, version, description: `${project.description} (${target})`,
os: [platform.os], cpu: [platform.cpu],
...(platform.libc ? { libc: [platform.libc] } : {}),
files: ['bin', 'build-info.json', 'source', 'LICENSE', 'README.md'],
});
const launcher = path.join(staging, 'launcher');
fs.mkdirSync(launcher);
for (const entry of ['npm', 'docs', 'README.md']) copy(`apps/cli/${entry}`, path.join(launcher, entry));
copy('LICENSE', path.join(launcher, 'LICENSE'));
fs.chmodSync(path.join(launcher, 'npm', 'cli.cjs'), 0o755);
json(path.join(launcher, 'package.json'), {
...metadata, name: project.name, version, description: project.description, engines: project.engines,
bin: { 'crossdesk-cli': 'npm/cli.cjs' },
os: ['darwin', 'linux', 'win32'], cpu: ['arm64', 'x64'],
files: ['npm', 'docs', 'README.md', 'LICENSE'],
optionalDependencies: Object.fromEntries(packageNames.map((name) => [name, version])),
});
// Finish both archives before publishing either one to the output directory.
const archives = path.join(staging, 'archives');
fs.mkdirSync(archives);
const packages = [pack(native, archives), pack(launcher, archives)];
for (const { filename } of packages) {
const destination = path.join(output, filename);
fs.copyFileSync(path.join(archives, filename), destination, fs.constants.COPYFILE_EXCL);
created.push(destination);
}
for (const { filename, size } of packages)
console.log(`${path.join(output, filename)} (${(size / 1024 / 1024).toFixed(2)} MiB)`);
console.log(`Native requirements: ${JSON.stringify(info)}`);
} catch (error) {
for (const file of created) fs.rmSync(file, { force: true });
console.error(`pack:npm: ${error.message}`);
process.exitCode = 1;
} finally {
if (staging) fs.rmSync(staging, { recursive: true, force: true });
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../../.."
git submodule update --init --recursive
patch_file="$(pwd)/deps/patches/minirtc-credential-logs.patch"
if git -C deps/submodules/minirtc apply --reverse --check "$patch_file" 2>/dev/null; then
exit 0
fi
git -C deps/submodules/minirtc apply --check "$patch_file"
git -C deps/submodules/minirtc apply "$patch_file"
+242
View File
@@ -0,0 +1,242 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const { setTimeout: sleep } = require('node:timers/promises');
const project = require('../package.json');
const platforms = require('../npm/platforms.json');
const registry = 'https://registry.npmjs.org/';
const repository = 'kunkundi/crossdesk';
function versionParts(version) {
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$/.exec(version);
assert(match && !(match[4] || '').split('.').some((part) => /^0\d+$/.test(part)),
`Invalid release version: ${version}`);
return [match.slice(1, 4).map(BigInt), match[4]?.split('.')];
}
function compareVersions(left, right) {
const [a, preA] = versionParts(left);
const [b, preB] = versionParts(right);
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;
if (!preA || !preB) return preA ? -1 : preB ? 1 : 0;
for (let i = 0; i < Math.max(preA.length, preB.length); i++) {
if (preA[i] === preB[i]) continue;
if (preA[i] === undefined || preB[i] === undefined) return preA[i] === undefined ? -1 : 1;
const numericA = /^\d+$/.test(preA[i]);
const numericB = /^\d+$/.test(preB[i]);
if (numericA !== numericB) return numericA ? -1 : 1;
return (numericA ? BigInt(preA[i]) > BigInt(preB[i]) : preA[i] > preB[i]) ? 1 : -1;
}
return 0;
}
function releasePlan() {
assert(['true', 'false'].includes(process.env.RELEASE_DRY_RUN), 'Set RELEASE_DRY_RUN=true or false explicitly.');
const dryRun = process.env.RELEASE_DRY_RUN === 'true';
const version = project.version;
versionParts(version);
assert.equal(project.name, '@chunkdash/crossdesk-cli', 'Unexpected entry package scope.');
const nativeVersion = fs.readFileSync(path.join(__dirname, '../xmake.lua'), 'utf8')
.match(/set_version\("([^"]+)"\)/)?.[1];
assert.equal(version, nativeVersion, 'package.json and xmake.lua versions must match.');
const ref = process.env.GITHUB_REF || '';
if (!dryRun) {
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Real publishing must run in GitHub Actions.');
assert.equal(process.env.GITHUB_REPOSITORY, repository, 'Unexpected publishing repository.');
assert(['push', 'workflow_dispatch'].includes(process.env.GITHUB_EVENT_NAME), 'Unsupported publishing event.');
assert.equal(ref, `refs/tags/cli-v${version}`, 'Publish only from the matching cli-vVERSION tag.');
} else if (ref.startsWith('refs/tags/')) {
assert.equal(ref, `refs/tags/cli-v${version}`, 'Git tag and package version must match, including in a dry run.');
}
return { version, tag: version.includes('-') ? 'next' : 'latest', dryRun };
}
function run(command, args, options = {}) {
return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024, ...options });
}
function npm(args, options) {
return process.env.npm_execpath
? run(process.execPath, [process.env.npm_execpath, ...args], options)
: run('npm', args, options);
}
function archiveRead(file, member, encoding = 'utf8') {
return run('tar', ['-xOf', file, `package/${member}`], { encoding });
}
function filename(name, version) {
return `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz`;
}
function inspect(file, name, version) {
const manifest = JSON.parse(archiveRead(file, 'package.json'));
assert.equal(manifest.name, name, `Unexpected package in ${file}`);
assert.equal(manifest.version, version, `Unexpected version in ${file}`);
assert(!manifest.private && !manifest.scripts, `${name}: publish only generated packages without lifecycle scripts.`);
assert.equal(manifest.repository?.url, project.repository.url, `${name}: repository mismatch.`);
assert.deepEqual(manifest.publishConfig, { access: 'public', registry }, `${name}: publishConfig mismatch.`);
const result = JSON.parse(npm(['pack', file, '--dry-run', '--json', '--ignore-scripts']));
// npm 12 returns a name-keyed object; npm 11 and earlier return an array.
const entries = Array.isArray(result) ? result : Object.values(result);
assert.equal(entries.length, 1, `Expected one package in ${file}`);
const [packed] = entries;
const integrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64')}`;
assert.equal(packed.integrity, integrity, `${name}: tarball integrity mismatch.`);
return { file, name, version, integrity, manifest, files: packed.files };
}
function collectPackages(directory, version) {
const targets = Object.keys(platforms);
assert.deepEqual(fs.readdirSync(directory).sort(), targets.map((target) => `npm-${target}`).sort(),
'Expected exactly five npm platform artifact directories from the same workflow run.');
const natives = [];
const launchers = [];
const dependencies = Object.fromEntries(Object.values(platforms).map(({ packageName }) => [packageName, version]));
for (const [target, platform] of Object.entries(platforms)) {
const folder = path.join(directory, `npm-${target}`);
const names = [filename(platform.packageName, version), filename(project.name, version)];
assert.deepEqual(fs.readdirSync(folder).sort(), [...names].sort(), `${target}: expected native and entry tarballs.`);
const native = inspect(path.join(folder, names[0]), platform.packageName, version);
assert.deepEqual(native.manifest.os, [platform.os]);
assert.deepEqual(native.manifest.cpu, [platform.cpu]);
assert.deepEqual(native.manifest.libc, platform.libc ? [platform.libc] : undefined);
assert(!native.manifest.bin, `${target}: native packages must not register commands.`);
const binary = `bin/crossdesk-cli${platform.os === 'win32' ? '.exe' : ''}`;
assert(native.files.some((file) => file.path === binary && (platform.os === 'win32' || (file.mode & 0o111))),
`${target}: missing executable binary.`);
const info = JSON.parse(archiveRead(native.file, 'build-info.json'));
assert.equal(info.version, version);
assert.equal(info.target, target);
assert.equal(info.sha256, crypto.createHash('sha256').update(archiveRead(native.file, binary, null)).digest('hex'),
`${target}: native binary checksum mismatch.`);
natives.push(native);
const launcher = inspect(path.join(folder, names[1]), project.name, version);
assert.deepEqual(launcher.manifest.optionalDependencies, dependencies, `${target}: platform dependencies mismatch.`);
assert.deepEqual(launcher.manifest.bin, { 'crossdesk-cli': 'npm/cli.cjs' });
assert.deepEqual(JSON.parse(archiveRead(launcher.file, 'npm/platforms.json')), platforms);
launchers.push({ target, ...launcher });
}
// Pick a Unix entry so the shebang and executable mode work on Unix as well.
const entry = launchers.find(({ target }) => target === 'linux-x64');
assert(entry.files.some((file) => file.path === 'npm/cli.cjs' && (file.mode & 0o111)), 'Entry launcher is not executable.');
const contents = Object.fromEntries(entry.files.map(({ path: member }) =>
[member, archiveRead(entry.file, member).replace(/\r\n/g, '\n')]));
for (const launcher of launchers) {
assert.deepEqual(launcher.files.map((file) => file.path).sort(), Object.keys(contents).sort(),
`${launcher.target}: entry package contents differ.`);
for (const [member, content] of Object.entries(contents)) {
assert.equal(archiveRead(launcher.file, member).replace(/\r\n/g, '\n'), content,
`${launcher.target}: entry file differs: ${member}`);
}
}
return [...natives, entry];
}
async function readRegistry(name) {
const response = await fetch(`${registry}${encodeURIComponent(name)}`, {
headers: { accept: 'application/json', 'cache-control': 'no-cache' },
signal: AbortSignal.timeout(30000),
});
if (response.status === 404) return null;
assert(response.ok, `${name}: registry returned HTTP ${response.status}; refusing to treat it as a missing package.`);
return response.json();
}
function checkPublished(pkg, document) {
const published = document?.versions?.[pkg.version];
if (!published) return false;
assert.equal(published.dist?.integrity, pkg.integrity,
`${pkg.name}@${pkg.version} already exists with different content. Bump the version; do not overwrite it.`);
return true;
}
async function waitForPackages(packages, tag) {
const deadline = Date.now() + 15 * 60 * 1000;
let pending = [...packages];
while (pending.length) {
const remaining = [];
for (const pkg of pending) {
const document = await readRegistry(pkg.name);
if (!checkPublished(pkg, document) || document['dist-tags']?.[tag] !== pkg.version) remaining.push(pkg);
}
if (!remaining.length) return;
assert(Date.now() < deadline, `npm is still processing: ${remaining.map((pkg) => pkg.name).join(', ')}. Wait, then re-run the failed job with the same artifacts.`);
console.log(`Waiting for npm versions and ${tag} tags: ${remaining.map((pkg) => pkg.name).join(', ')}`);
pending = remaining;
await sleep(15000);
}
}
async function publishPackages(packages, { tag, dryRun }) {
const existing = new Set();
// Preflight every package before making any registry changes.
for (const pkg of packages) {
if (dryRun) continue;
const document = await readRegistry(pkg.name);
const current = document?.['dist-tags']?.[tag];
assert(!current || compareVersions(current, pkg.version) <= 0,
`${pkg.name}: ${tag} already points to newer ${current}; refusing to roll it back.`);
if (checkPublished(pkg, document)) {
assert.equal(current, pkg.version, `${pkg.name}: existing version matches but ${tag} does not. Review dist-tags manually before retrying.`);
existing.add(pkg.name);
} else {
const versions = Object.keys(document?.versions || {});
assert(!(versions.length === 1 && versions[0] === '0.0.0-stage'),
`${pkg.name}: npm still has a staging placeholder. Wait for processing before retrying; do not upload it again.`);
}
}
const publish = (pkg) => {
if (dryRun) {
// collectPackages already ran npm pack --dry-run and inspected the archive.
// npm publish --dry-run still exchanges OIDC credentials and rejects
// existing versions, so it cannot be used before trust is configured.
console.log(`Validated tarball without publishing: ${pkg.name}@${pkg.version} (${tag})`);
return;
}
if (existing.has(pkg.name)) {
console.log(`Already published with matching integrity: ${pkg.name}@${pkg.version}`);
return;
}
console.log(`Publishing: ${pkg.name}@${pkg.version} (${tag})`);
npm(['publish', pkg.file, '--access', 'public', '--tag', tag, '--ignore-scripts',
`--registry=${registry}`, '--fetch-retries=0', '--fetch-timeout=120000',
'--provenance'], { stdio: 'inherit' });
};
const native = packages.slice(0, -1);
for (const pkg of native) publish(pkg);
if (!dryRun) await waitForPackages(native, tag);
const entry = packages.at(-1);
publish(entry);
if (!dryRun) await waitForPackages([entry], tag);
const report = `${dryRun ? 'Validated (no packages published)' : 'Published and verified'} CLI ${entry.version} with npm tag ${tag}.\n\n` +
'| Package | Integrity (SHA-512) |\n| --- | --- |\n' + packages.map((pkg) => `| ${pkg.name} | ${pkg.integrity} |`).join('\n') + '\n';
console.log(report);
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
}
async function main() {
const [command, directory, ...extra] = process.argv.slice(2);
assert((command === 'plan' && !directory) || (command === 'publish' && directory && !extra.length),
'Usage: release-npm.cjs plan | publish ARTIFACT_DIRECTORY (set RELEASE_DRY_RUN=true or false)');
const plan = releasePlan();
console.log(`CLI ${plan.version}, npm tag ${plan.tag}, dry run: ${plan.dryRun}`);
if (command === 'plan') {
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
`version=${plan.version}\ntag=${plan.tag}\ndry_run=${plan.dryRun}\n`);
return;
}
await publishPackages(collectPackages(path.resolve(directory), plan.version), plan);
}
if (require.main === module) main().catch((error) => {
console.error(`release:npm: ${error.message}`);
process.exitCode = 1;
});
module.exports = { releasePlan, compareVersions, collectPackages, publishPackages };
+78
View File
@@ -0,0 +1,78 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-30
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _TERMINAL_IO_H_
#define _TERMINAL_IO_H_
#include <atomic>
#include <csignal>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
namespace crossdesk::cli {
static_assert(std::atomic<int>::is_always_lock_free);
extern std::atomic<int> interrupted;
void InitializeIo();
const char *Platform();
bool IsTty(int fd);
std::ptrdiff_t Read(int fd, void *buffer, size_t size);
std::ptrdiff_t Write(int fd, const void *buffer, size_t size);
void Close(int fd);
int Duplicate(int fd);
int Redirect(int from, int to);
int OpenLog(const std::string &path);
bool WaitReadable(int fd, int timeout_ms);
void WaitIo(int input_fd, int output_fd, int timeout_ms);
std::string Password();
std::string Logs();
struct TerminalSize {
int columns = 80, rows = 25, pixel_width = 0, pixel_height = 0;
};
TerminalSize GetTerminalSize(int fd);
// One private, uniquely named pixel buffer. Keep it alive until the terminal
// acknowledges reading it; Reset also unlinks objects the terminal left behind.
class SharedImage {
public:
SharedImage();
~SharedImage();
bool Assign(const uint8_t *data, size_t size);
void Reset();
const std::string &Name() const;
private:
struct State;
std::unique_ptr<State> state_;
};
class NonBlocking {
public:
explicit NonBlocking(int fd, bool output = false);
~NonBlocking();
NonBlocking(const NonBlocking &) = delete;
NonBlocking &operator=(const NonBlocking &) = delete;
private:
struct State;
std::unique_ptr<State> state_;
};
class RawTerminal {
public:
RawTerminal(int input, int output);
~RawTerminal();
RawTerminal(const RawTerminal &) = delete;
RawTerminal &operator=(const RawTerminal &) = delete;
private:
struct State;
std::unique_ptr<State> state_;
};
} // namespace crossdesk::cli
#endif
+228
View File
@@ -0,0 +1,228 @@
#ifndef _WIN32
#include "terminal_io.h"
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <poll.h>
#include <stdexcept>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/select.h>
#include <termios.h>
#include <unistd.h>
namespace crossdesk::cli {
std::atomic<int> interrupted{0};
namespace {
void Signal(int value) { interrupted.store(value, std::memory_order_relaxed); }
} // namespace
void InitializeIo() {
struct sigaction action{};
action.sa_handler = Signal;
sigemptyset(&action.sa_mask);
for (int sig : {SIGINT, SIGTERM, SIGHUP})
sigaction(sig, &action, nullptr);
std::signal(SIGPIPE, SIG_IGN);
}
const char *Platform() {
#ifdef __APPLE__
return "macos";
#else
return "linux";
#endif
}
bool IsTty(int fd) { return isatty(fd); }
std::ptrdiff_t Read(int fd, void *buffer, size_t size) {
return read(fd, buffer, size);
}
std::ptrdiff_t Write(int fd, const void *buffer, size_t size) {
return write(fd, buffer, size);
}
void Close(int fd) { close(fd); }
int Duplicate(int fd) { return dup(fd); }
int Redirect(int from, int to) { return dup2(from, to); }
int OpenLog(const std::string &path) {
return open(path.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0600);
}
bool WaitReadable(int fd, int timeout_ms) {
pollfd p{fd, POLLIN, 0};
int result = poll(&p, 1, timeout_ms);
if (result < 0 && errno != EINTR)
throw std::runtime_error("cannot poll input descriptor");
if (p.revents & POLLNVAL) {
// Darwin's /dev/tty may reject poll even though the descriptor is valid.
// select supports that controlling-terminal device without a blocking read.
if (fd < 0 || fd >= FD_SETSIZE || fcntl(fd, F_GETFL) < 0)
throw std::runtime_error("input descriptor unavailable");
fd_set readers;
FD_ZERO(&readers);
FD_SET(fd, &readers);
timeval timeout{timeout_ms / 1000, (timeout_ms % 1000) * 1000};
result = select(fd + 1, &readers, nullptr, nullptr, &timeout);
if (result < 0 && errno != EINTR)
throw std::runtime_error("cannot wait for terminal input");
return result > 0;
}
return result > 0 && (p.revents & (POLLIN | POLLHUP | POLLERR));
}
void WaitIo(int input_fd, int output_fd, int timeout_ms) {
pollfd fds[2] = {{input_fd, POLLIN, 0}, {output_fd, POLLOUT, 0}};
poll(fds, 2, timeout_ms);
}
TerminalSize GetTerminalSize(int fd) {
winsize ws{};
TerminalSize size;
if (ioctl(fd, TIOCGWINSZ, &ws) == 0 && ws.ws_col && ws.ws_row) {
size.columns = ws.ws_col;
size.rows = ws.ws_row;
size.pixel_width = ws.ws_xpixel;
size.pixel_height = ws.ws_ypixel;
}
return size;
}
struct SharedImage::State {
std::string name;
void *data = MAP_FAILED;
size_t size = 0;
};
SharedImage::SharedImage() : state_(std::make_unique<State>()) {}
SharedImage::~SharedImage() { Reset(); }
const std::string &SharedImage::Name() const { return state_->name; }
void SharedImage::Reset() {
if (state_->data != MAP_FAILED)
munmap(state_->data, state_->size);
if (!state_->name.empty())
shm_unlink(state_->name.c_str());
state_->data = MAP_FAILED;
state_->size = 0;
state_->name.clear();
}
bool SharedImage::Assign(const uint8_t *data, size_t size) {
Reset();
if (!data || !size || size > 1920 * 1080 * 3)
return false;
static std::atomic<uint32_t> sequence{0};
int fd = -1;
for (int attempt = 0; attempt < 16; ++attempt) {
// Fits Darwin's 31-byte name limit. O_EXCL prevents opening another
// process's object, including a stale name from a recycled process ID.
auto name = "/cdx-" + std::to_string(getpid()) + "-" +
std::to_string(++sequence);
fd = shm_open(name.c_str(), O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd >= 0) {
state_->name = std::move(name);
break;
}
if (errno != EEXIST)
return false;
}
if (fd < 0)
return false;
fcntl(fd, F_SETFD, FD_CLOEXEC);
#ifdef __linux__
// Reserve tmpfs space before touching the mapping; a successful ftruncate
// alone can still SIGBUS later when /dev/shm is full.
if (posix_fallocate(fd, 0, size) != 0) {
close(fd);
Reset();
return false;
}
#endif
if (ftruncate(fd, size) == 0) {
state_->data = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
state_->size = size;
}
close(fd);
if (state_->data == MAP_FAILED) {
Reset();
return false;
}
std::memcpy(state_->data, data, size);
return true;
}
struct NonBlocking::State {
int fd, flags;
};
NonBlocking::NonBlocking(int fd, bool) : state_(std::make_unique<State>()) {
state_->fd = fd;
state_->flags = fcntl(fd, F_GETFL);
if (state_->flags < 0 || fcntl(fd, F_SETFL, state_->flags | O_NONBLOCK) < 0)
throw std::runtime_error("cannot configure nonblocking descriptor");
}
NonBlocking::~NonBlocking() { fcntl(state_->fd, F_SETFL, state_->flags); }
struct RawTerminal::State {
int fd, flags;
termios saved;
};
RawTerminal::RawTerminal(int input, int) : state_(std::make_unique<State>()) {
state_->fd = input;
state_->flags = fcntl(input, F_GETFL);
if (state_->flags < 0 || tcgetattr(input, &state_->saved) != 0)
throw std::runtime_error("cannot read terminal attributes");
termios mode = state_->saved;
cfmakeraw(&mode);
if (tcsetattr(input, TCSAFLUSH, &mode) != 0)
throw std::runtime_error("cannot enter terminal raw mode");
if (fcntl(input, F_SETFL, state_->flags | O_NONBLOCK) < 0) {
tcsetattr(input, TCSAFLUSH, &state_->saved);
throw std::runtime_error("cannot configure terminal input");
}
}
RawTerminal::~RawTerminal() {
// TCSAFLUSH waits for queued output, which can hang exit when the terminal
// stops consuming a partially written image. Restore immediately and flush
// only pending input so protocol replies do not reach the next shell prompt.
tcsetattr(state_->fd, TCSANOW, &state_->saved);
tcflush(state_->fd, TCIFLUSH);
fcntl(state_->fd, F_SETFL, state_->flags);
}
std::string Password() {
int fd = open("/dev/tty", O_RDWR | O_NOCTTY);
if (fd < 0)
throw std::runtime_error("password requires an interactive TTY");
struct Restore {
int fd;
termios mode{};
bool active = false;
~Restore() {
if (active) {
tcsetattr(fd, TCSAFLUSH, &mode);
write(fd, "\n", 1);
}
close(fd);
}
} restore{fd};
if (tcgetattr(fd, &restore.mode))
throw std::runtime_error("cannot read password TTY");
auto mode = restore.mode;
mode.c_lflag &= ~ECHO;
if (tcsetattr(fd, TCSAFLUSH, &mode))
throw std::runtime_error("cannot disable password echo");
restore.active = true;
const char prompt[] = "Remote password: ";
write(fd, prompt, sizeof(prompt) - 1);
std::string result;
while (!interrupted) {
if (!WaitReadable(fd, 100))
continue;
char c;
auto n = read(fd, &c, 1);
if (n < 0 && errno == EINTR)
continue;
if (n <= 0 || result.size() > 256)
break;
if (c == '\n' || c == '\r')
return result;
result += c;
}
throw std::runtime_error("password entry cancelled or too long");
}
std::string Logs() {
char path[] = "/tmp/crossdesk-cli-XXXXXX";
char *dir = mkdtemp(path);
if (!dir)
throw std::runtime_error("cannot create private log directory");
return dir;
}
} // namespace crossdesk::cli
#endif
+499
View File
@@ -0,0 +1,499 @@
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include "terminal_io.h"
#include <windows.h>
#include <algorithm>
#include <cerrno>
#include <chrono>
#include <fcntl.h>
#include <io.h>
#include <random>
#include <sddl.h>
#include <stdexcept>
#include <sys/stat.h>
#include <thread>
namespace crossdesk::cli {
std::atomic<int> interrupted{0};
namespace {
int console_output = -1;
std::string input_bytes;
wchar_t high_surrogate = 0;
DWORD mouse_buttons = 0;
HANDLE Handle(int fd) {
#ifdef _MSC_VER
// A missing optional credential fd should be an error response, not a CRT
// invalid-parameter process termination. Keep the handler thread-local.
auto previous = _set_thread_local_invalid_parameter_handler(
[](const wchar_t *, const wchar_t *, const wchar_t *, unsigned,
uintptr_t) {});
auto value = _get_osfhandle(fd);
_set_thread_local_invalid_parameter_handler(previous);
return reinterpret_cast<HANDLE>(value);
#else
return reinterpret_cast<HANDLE>(_get_osfhandle(fd));
#endif
}
void Signal(int value) { interrupted.store(value, std::memory_order_relaxed); }
BOOL WINAPI ConsoleSignal(DWORD value) {
if (value == CTRL_C_EVENT || value == CTRL_BREAK_EVENT) {
interrupted.store(SIGINT, std::memory_order_relaxed);
return TRUE;
}
return FALSE;
}
std::string Utf8(const std::wstring &value) {
if (value.empty())
return {};
int size =
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(),
int(value.size()), nullptr, 0, nullptr, nullptr);
if (!size)
throw std::runtime_error("invalid Unicode input");
std::string result(size, '\0');
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(),
int(value.size()), result.data(), size, nullptr, nullptr);
return result;
}
std::wstring Wide(const std::string &value) {
int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
int(value.size()), nullptr, 0);
if (!size)
throw std::runtime_error("invalid Unicode path");
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
int(value.size()), result.data(), size);
return result;
}
void Character(wchar_t c) {
if (c >= 0xd800 && c <= 0xdbff) {
high_surrogate = c;
return;
}
if (c >= 0xdc00 && c <= 0xdfff) {
if (high_surrogate)
input_bytes += Utf8(std::wstring{high_surrogate, c});
} else if (c) {
input_bytes += Utf8(std::wstring(1, c));
}
high_surrogate = 0;
}
void Key(const KEY_EVENT_RECORD &key) {
if (!key.bKeyDown)
return;
const char *sequence = nullptr;
switch (key.wVirtualKeyCode) {
case VK_UP:
sequence = "\033[A";
break;
case VK_DOWN:
sequence = "\033[B";
break;
case VK_RIGHT:
sequence = "\033[C";
break;
case VK_LEFT:
sequence = "\033[D";
break;
case VK_HOME:
sequence = "\033[H";
break;
case VK_END:
sequence = "\033[F";
break;
case VK_DELETE:
sequence = "\033[3~";
break;
case VK_PRIOR:
sequence = "\033[5~";
break;
case VK_NEXT:
sequence = "\033[6~";
break;
case VK_F1:
sequence = "\033OP";
break;
case VK_F2:
sequence = "\033OQ";
break;
case VK_F3:
sequence = "\033OR";
break;
case VK_F4:
sequence = "\033OS";
break;
}
for (int i = 0; i < std::min<int>(key.wRepeatCount, 64); ++i) {
if (sequence)
input_bytes += sequence;
else {
// Right Alt + Ctrl is AltGr, not an Alt shortcut.
bool alt = key.dwControlKeyState & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED);
bool ctrl =
key.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED);
if (alt && !ctrl && key.uChar.UnicodeChar)
input_bytes += '\033';
Character(key.uChar.UnicodeChar);
}
}
}
void Mouse(const MOUSE_EVENT_RECORD &mouse) {
CONSOLE_SCREEN_BUFFER_INFO info{};
if (console_output < 0 ||
!GetConsoleScreenBufferInfo(Handle(console_output), &info))
return;
int x = mouse.dwMousePosition.X - info.srWindow.Left + 1;
int y = mouse.dwMousePosition.Y - info.srWindow.Top + 1;
if (x < 1 || y < 1)
return;
auto emit = [&](int button, bool release = false) {
input_bytes += "\033[<" + std::to_string(button) + ";" + std::to_string(x) +
";" + std::to_string(y) + (release ? "m" : "M");
};
DWORD buttons = mouse.dwButtonState & 0xffff;
const DWORD masks[] = {FROM_LEFT_1ST_BUTTON_PRESSED,
FROM_LEFT_2ND_BUTTON_PRESSED,
RIGHTMOST_BUTTON_PRESSED};
for (int i = 0; i < 3; ++i)
if ((buttons & masks[i]) != (mouse_buttons & masks[i]))
emit(i, !(buttons & masks[i]));
if (mouse.dwEventFlags & (MOUSE_WHEELED | MOUSE_HWHEELED)) {
int delta = static_cast<SHORT>(HIWORD(mouse.dwButtonState));
emit(64 + ((mouse.dwEventFlags & MOUSE_HWHEELED) ? 2 : 0) + (delta < 0));
} else if (mouse.dwEventFlags & MOUSE_MOVED) {
emit(32 + ((buttons & masks[0]) ? 0
: (buttons & masks[1]) ? 1
: (buttons & masks[2]) ? 2
: 3));
}
mouse_buttons = buttons;
}
bool Ready(int fd) {
if (fd < 0)
return false;
HANDLE handle = Handle(fd);
if (handle == INVALID_HANDLE_VALUE)
throw std::runtime_error("input descriptor unavailable");
DWORD mode = 0;
if (GetConsoleMode(handle, &mode)) {
DWORD count = 0;
return !input_bytes.empty() ||
(GetNumberOfConsoleInputEvents(handle, &count) && count);
}
if (GetFileType(handle) == FILE_TYPE_PIPE) {
DWORD available = 0;
if (PeekNamedPipe(handle, nullptr, 0, nullptr, &available, nullptr))
return available != 0;
if (GetLastError() == ERROR_BROKEN_PIPE)
return true;
throw std::runtime_error("cannot inspect input pipe");
}
return true;
}
} // namespace
void InitializeIo() {
// Preserve LF-delimited JSON and terminal escape bytes through CRT streams.
for (int fd : {0, 1, 2})
_setmode(fd, _O_BINARY);
std::signal(SIGINT, Signal);
std::signal(SIGTERM, Signal);
SetConsoleCtrlHandler(ConsoleSignal, TRUE);
}
const char *Platform() { return "windows"; }
bool IsTty(int fd) {
DWORD mode;
return GetConsoleMode(Handle(fd), &mode) != 0;
}
std::ptrdiff_t Read(int fd, void *buffer, size_t size) {
if (!size)
return 0;
HANDLE handle = Handle(fd);
DWORD mode;
if (GetConsoleMode(handle, &mode)) {
if (input_bytes.empty()) {
DWORD count = 0, read = 0;
if (!GetNumberOfConsoleInputEvents(handle, &count)) {
errno = EIO;
return -1;
}
INPUT_RECORD records[128];
if (count && ReadConsoleInputW(handle, records,
std::min<DWORD>(count, 128), &read))
for (DWORD i = 0; i < read; ++i) {
if (records[i].EventType == KEY_EVENT)
Key(records[i].Event.KeyEvent);
else if (records[i].EventType == MOUSE_EVENT)
Mouse(records[i].Event.MouseEvent);
else if (records[i].EventType == FOCUS_EVENT &&
!records[i].Event.FocusEvent.bSetFocus)
input_bytes += "\033[O";
}
}
if (input_bytes.empty()) {
errno = EAGAIN;
return -1;
}
size_t count = std::min(size, input_bytes.size());
std::copy_n(input_bytes.data(), count, static_cast<char *>(buffer));
input_bytes.erase(0, count);
return count;
}
if (GetFileType(handle) == FILE_TYPE_PIPE) {
DWORD available = 0;
if (!PeekNamedPipe(handle, nullptr, 0, nullptr, &available, nullptr)) {
if (GetLastError() == ERROR_BROKEN_PIPE)
return 0;
errno = EIO;
return -1;
}
if (!available) {
errno = EAGAIN;
return -1;
}
size = std::min<size_t>(size, available);
}
DWORD count = 0;
if (!ReadFile(handle, buffer, DWORD(std::min<size_t>(size, 16384)), &count,
nullptr)) {
if (GetLastError() == ERROR_BROKEN_PIPE)
return 0;
errno = EIO;
return -1;
}
return count;
}
std::ptrdiff_t Write(int fd, const void *buffer, size_t size) {
DWORD count = 0;
if (!WriteFile(Handle(fd), buffer, DWORD(std::min<size_t>(size, 16384)),
&count, nullptr)) {
errno = GetLastError() == ERROR_NO_DATA ? EPIPE : EIO;
return -1;
}
if (!count && size) {
errno = EAGAIN;
return -1;
}
return count;
}
void Close(int fd) { _close(fd); }
int Duplicate(int fd) { return _dup(fd); }
int Redirect(int from, int to) { return _dup2(from, to); }
int OpenLog(const std::string &path) {
return _wopen(Wide(path).c_str(),
_O_CREAT | _O_WRONLY | _O_APPEND | _O_BINARY,
_S_IREAD | _S_IWRITE);
}
bool WaitReadable(int fd, int timeout_ms) {
auto until =
std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
do {
if (Ready(fd))
return true;
if (interrupted)
return false;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
} while (std::chrono::steady_clock::now() < until);
return false;
}
void WaitIo(int input_fd, int, int timeout_ms) {
WaitReadable(input_fd, timeout_ms);
}
TerminalSize GetTerminalSize(int) {
TerminalSize size;
HANDLE handle = console_output >= 0
? Handle(console_output)
: CreateFileW(L"CONOUT$", GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
OPEN_EXISTING, 0, nullptr);
CONSOLE_SCREEN_BUFFER_INFO info{};
if (GetConsoleScreenBufferInfo(handle, &info)) {
size.columns = info.srWindow.Right - info.srWindow.Left + 1;
size.rows = info.srWindow.Bottom - info.srWindow.Top + 1;
// ConPTY's console window is only a message window, and its font API
// describes the hidden console host, not the user's display terminal.
// Only trust native font metrics for a visible classic console.
HWND window = GetConsoleWindow();
CONSOLE_FONT_INFOEX font{};
font.cbSize = sizeof(font);
if (window && IsWindowVisible(window) &&
GetCurrentConsoleFontEx(handle, FALSE, &font) &&
font.dwFontSize.X > 0 && font.dwFontSize.X <= 1024 &&
font.dwFontSize.Y > 0 && font.dwFontSize.Y <= 1024) {
size.pixel_width = size.columns * font.dwFontSize.X;
size.pixel_height = size.rows * font.dwFontSize.Y;
}
}
if (console_output < 0 && handle != INVALID_HANDLE_VALUE)
CloseHandle(handle);
return size;
}
struct SharedImage::State {
std::string name;
};
SharedImage::SharedImage() : state_(std::make_unique<State>()) {}
SharedImage::~SharedImage() = default;
bool SharedImage::Assign(const uint8_t *, size_t) { return false; }
void SharedImage::Reset() {}
const std::string &SharedImage::Name() const { return state_->name; }
struct NonBlocking::State {
HANDLE handle;
DWORD mode = 0;
bool restore = false;
};
NonBlocking::NonBlocking(int fd, bool output)
: state_(std::make_unique<State>()) {
state_->handle = Handle(fd);
// Reads use PeekNamedPipe; output pipes additionally require bounded writes.
if (output && GetFileType(state_->handle) == FILE_TYPE_PIPE) {
DWORD flags = 0;
if (!GetNamedPipeHandleStateW(state_->handle, &state_->mode, nullptr,
nullptr, nullptr, nullptr, 0))
throw std::runtime_error("cannot read pipe mode");
flags = state_->mode | PIPE_NOWAIT;
if (!SetNamedPipeHandleState(state_->handle, &flags, nullptr, nullptr))
throw std::runtime_error("cannot configure nonblocking pipe");
state_->restore = true;
}
}
NonBlocking::~NonBlocking() {
if (state_->restore)
SetNamedPipeHandleState(state_->handle, &state_->mode, nullptr, nullptr);
}
struct RawTerminal::State {
HANDLE input, output;
DWORD input_mode, output_mode;
UINT input_cp, output_cp;
State(HANDLE in, HANDLE out, DWORD in_mode, DWORD out_mode)
: input(in), output(out), input_mode(in_mode), output_mode(out_mode),
input_cp(GetConsoleCP()), output_cp(GetConsoleOutputCP()) {}
State(const State &) = delete;
State &operator=(const State &) = delete;
~State() {
SetConsoleMode(input, input_mode);
SetConsoleMode(output, output_mode);
SetConsoleCP(input_cp);
SetConsoleOutputCP(output_cp);
console_output = -1;
input_bytes.clear();
high_surrogate = 0;
mouse_buttons = 0;
}
};
RawTerminal::RawTerminal(int input, int output) {
DWORD in_mode = 0, out_mode = 0;
if (!GetConsoleMode(Handle(input), &in_mode) ||
!GetConsoleMode(Handle(output), &out_mode))
throw std::runtime_error(
"view requires a Windows console or ConPTY terminal");
state_ = std::make_unique<State>(Handle(input), Handle(output), in_mode, out_mode);
DWORD mode = (in_mode & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT |
ENABLE_PROCESSED_INPUT | ENABLE_QUICK_EDIT_MODE |
ENABLE_VIRTUAL_TERMINAL_INPUT)) |
ENABLE_EXTENDED_FLAGS | ENABLE_WINDOW_INPUT | ENABLE_MOUSE_INPUT;
if (!SetConsoleMode(Handle(input), mode) ||
!SetConsoleMode(Handle(output), out_mode | ENABLE_PROCESSED_OUTPUT |
ENABLE_VIRTUAL_TERMINAL_PROCESSING) ||
!SetConsoleCP(CP_UTF8) || !SetConsoleOutputCP(CP_UTF8))
throw std::runtime_error(
"terminal requires Windows 10 or newer with VT support");
console_output = output;
}
RawTerminal::~RawTerminal() = default;
std::string Password() {
HANDLE input = CreateFileW(L"CONIN$", GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
OPEN_EXISTING, 0, nullptr);
HANDLE output =
CreateFileW(L"CONOUT$", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr, OPEN_EXISTING, 0, nullptr);
struct Restore {
HANDLE input, output;
DWORD mode = 0;
bool active = false;
~Restore() {
if (active) {
SetConsoleMode(input, mode);
DWORD count;
WriteConsoleW(output, L"\r\n", 2, &count, nullptr);
}
if (input != INVALID_HANDLE_VALUE)
CloseHandle(input);
if (output != INVALID_HANDLE_VALUE)
CloseHandle(output);
}
} restore{input, output};
if (input == INVALID_HANDLE_VALUE || output == INVALID_HANDLE_VALUE ||
!GetConsoleMode(input, &restore.mode))
throw std::runtime_error("password requires an interactive console");
if (!SetConsoleMode(
input, (restore.mode & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT)) |
ENABLE_PROCESSED_INPUT))
throw std::runtime_error("cannot disable password echo");
restore.active = true;
DWORD count;
WriteConsoleW(output, L"Remote password: ", 17, &count, nullptr);
std::wstring value;
while (!interrupted) {
if (WaitForSingleObject(input, 100) != WAIT_OBJECT_0)
continue;
INPUT_RECORD record{};
if (!ReadConsoleInputW(input, &record, 1, &count))
break;
if (!count || record.EventType != KEY_EVENT ||
!record.Event.KeyEvent.bKeyDown)
continue;
wchar_t c = record.Event.KeyEvent.uChar.UnicodeChar;
if (c == L'\r') {
auto result = Utf8(value);
if (result.size() <= 256)
return result;
break;
}
if (c == L'\b') {
if (!value.empty()) {
wchar_t last = value.back();
value.pop_back();
if (last >= 0xdc00 && last <= 0xdfff && !value.empty())
value.pop_back();
}
} else if (c >= 32) {
value += c;
if (value.size() > 256)
break;
}
}
throw std::runtime_error("password entry cancelled or too long");
}
std::string Logs() {
wchar_t root[MAX_PATH + 1];
DWORD size = GetTempPathW(MAX_PATH + 1, root);
if (!size || size > MAX_PATH)
throw std::runtime_error("cannot locate temporary directory");
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;OW)", SDDL_REVISION_1, &descriptor,
nullptr))
throw std::runtime_error("cannot secure log directory");
struct Release {
PSECURITY_DESCRIPTOR p;
~Release() { LocalFree(p); }
} release{descriptor};
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), descriptor, FALSE};
std::random_device random;
for (int attempt = 0; attempt < 10; ++attempt) {
std::wstring path = std::wstring(root) + L"crossdesk-cli-" +
std::to_wstring(random()) + L"-" +
std::to_wstring(random());
if (CreateDirectoryW(path.c_str(), &security))
return Utf8(path);
if (GetLastError() != ERROR_ALREADY_EXISTS)
break;
}
throw std::runtime_error("cannot create private log directory");
}
} // namespace crossdesk::cli
#endif
+50
View File
@@ -0,0 +1,50 @@
local source_dir = path.absolute("../..", os.scriptdir())
local controller_dir = path.join(source_dir, "libs/controller")
local terminal_dir = path.join(source_dir, "libs/terminal")
local minirtc_dir = path.join(source_dir, "deps/submodules/minirtc")
local credential_patch = path.join(source_dir, "deps/patches/minirtc-credential-logs.patch")
add_requires("spdlog 1.14.1", {system = false})
if is_plat("windows") then
add_requires("zlib 1.3.1", {system = false, configs = {shared = false}})
end
target("crossdesk_controller")
set_kind("static")
add_deps("minirtc", "crossdesk_wire")
add_packages("spdlog")
add_includedirs(path.join(controller_dir, "include"),
path.join(minirtc_dir, "src/api"), {public = true})
add_includedirs(path.join(minirtc_dir, "src/log"))
add_files(path.join(controller_dir, "src/*.cpp"))
before_build(function ()
-- Require the same credential redaction in checkouts and source bundles.
os.vrunv("git", {"-C", minirtc_dir, "apply", "--reverse", "--check", credential_patch})
end)
target_end()
target("crossdesk_terminal")
set_kind("static")
add_deps("crossdesk_controller")
add_includedirs(path.join(terminal_dir, "include"), {public = true})
add_files(path.join(terminal_dir, "src/*.cpp"))
if is_plat("windows") then
add_packages("zlib", {public = true})
else
add_syslinks("z", {public = true})
end
target_end()
target("crossdesk-cli")
set_kind("binary")
set_version("0.1.2")
add_deps("crossdesk_terminal")
add_files("main.cpp")
add_files(is_plat("windows") and "terminal_io_windows.cpp" or "terminal_io_posix.cpp")
if is_plat("windows") then
add_syslinks("advapi32", "user32")
add_defines("NOMINMAX", "WIN32_LEAN_AND_MEAN")
elseif is_plat("linux") then
add_syslinks("rt")
end
target_end()
Binary file not shown.
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0" name="CrossDesk.SessionHelper" type="win32" />
<description>CrossDesk Session Helper</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
@@ -4,13 +4,10 @@ IDI_ICON1 ICON "crossdesk.ico"
#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1
#define RT_MANIFEST 24
#ifdef CROSSDESK_PORTABLE
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest"
#elif defined(CROSSDESK_DEBUG)
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest"
#else
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
#endif
// The service supplies either the user's token (desktop observer) or a SYSTEM
// token (secure capture/input). Requiring elevation here prevents the observer
// from starting with CreateProcessAsUserW in installed release builds (740).
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_session_helper.manifest"
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe"
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0" name="CrossDesk.Updater" type="win32"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
@@ -0,0 +1,5 @@
1 24 "crossdesk_updater.manifest"
IDI_ICON1 ICON "crossdesk.ico"
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Updater"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_updater.exe"
#include "version_info.rcinc"
+2 -1
View File
@@ -111,6 +111,7 @@ DESKTOP_DIR="$DEB_DIR/usr/share/applications"
install -d "$DEBIAN_DIR" "$BIN_DIR" "$PRIVATE_DIR" "$DESKTOP_DIR"
install -m 0755 "$BUILD_BINARY" "$PRIVATE_DIR/crossdesk-bin"
install -m 0755 "$(dirname "$BUILD_BINARY")/crossdesk_updater" "$PRIVATE_DIR/crossdesk_updater"
# PipeWire is deliberately optional. The Wayland capturer resolves PipeWire
# 0.3 with dlopen() so the same package can run on Ubuntu 20.04 without the
@@ -156,7 +157,7 @@ for size in 16 24 32 48 64 96 128 256; do
"$ICON_BASE_DIR/${size}x${size}/apps/${PKG_NAME}.png"
done
RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS, $TRAY_RUNTIME_RECOMMENDS"
RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS, $TRAY_RUNTIME_RECOMMENDS, policykit-1"
if [[ -n "$EXTRA_RECOMMENDATION" ]]; then
RECOMMENDS="$RECOMMENDS, $EXTRA_RECOMMENDATION"
fi
+4 -4
View File
@@ -127,9 +127,9 @@ fi
# The package cache remains intact for debugging and other consumers.
strip -S -x "$FRAMEWORKS_DIR/$SLINT_NAME"
# install_name_tool and strip invalidate the linker's ad-hoc signature. Sign nested code
# first, then seal the complete app bundle so local packages remain launchable.
codesign --force --sign - "$FRAMEWORKS_DIR/$SLINT_NAME"
codesign --force --deep --sign - "$APP_BUNDLE"
# install_name_tool and strip invalidate existing signatures. Sign only after
# all bundle changes, using Developer ID when configured or ad-hoc for local use.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
bash "$SCRIPT_DIR/sign_app.sh" "$APP_BUNDLE"
echo "Bundled Slint runtime: $SLINT_LIBRARY"
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 || -z "${CROSSDESK_MACOS_NOTARY_PROFILE:-}" ]]; then
echo "Usage: CROSSDESK_MACOS_NOTARY_PROFILE=<keychain-profile> $0 <signed.pkg>" >&2
echo "Create the profile with: xcrun notarytool store-credentials <keychain-profile>" >&2
exit 2
fi
PACKAGE_FILE="$1"
if [[ ! -f "$PACKAGE_FILE" || "$PACKAGE_FILE" != *.pkg ]]; then
echo "Expected a signed .pkg file: $PACKAGE_FILE" >&2
exit 1
fi
pkgutil --check-signature "$PACKAGE_FILE"
NOTARY_ARGS=(--keychain-profile "$CROSSDESK_MACOS_NOTARY_PROFILE")
if [[ -n "${CROSSDESK_MACOS_KEYCHAIN:-}" ]]; then
NOTARY_ARGS+=(--keychain "$CROSSDESK_MACOS_KEYCHAIN")
fi
UPLOAD_ARGS=()
case "${CROSSDESK_MACOS_NOTARY_S3_ACCELERATION:-true}" in
true) UPLOAD_ARGS+=(--s3-acceleration) ;;
false) UPLOAD_ARGS+=(--no-s3-acceleration) ;;
*)
echo "CROSSDESK_MACOS_NOTARY_S3_ACCELERATION must be true or false." >&2
exit 2
;;
esac
# Retain the submission response and log beside the installer for diagnostics
# and for resuming a submission if Apple's service takes longer than expected.
SUBMIT_FILE="${PACKAGE_FILE}.notary-submit.json"
RESULT_FILE="${PACKAGE_FILE}.notary.json"
LOG_FILE="${PACKAGE_FILE}.notary-log.json"
SUBMIT_STATUS=0
xcrun notarytool submit "$PACKAGE_FILE" "${NOTARY_ARGS[@]}" "${UPLOAD_ARGS[@]}" \
--no-wait --output-format json > "$SUBMIT_FILE" || SUBMIT_STATUS=$?
cat "$SUBMIT_FILE"
SUBMISSION_ID="$(plutil -extract id raw -o - "$SUBMIT_FILE" 2>/dev/null || true)"
if [[ "$SUBMIT_STATUS" -ne 0 || -z "$SUBMISSION_ID" ]]; then
echo "Notarization upload failed; response: $SUBMIT_FILE" >&2
echo "Check notarytool history before retrying; Apple may have allocated a submission ID." >&2
exit 1
fi
echo "Waiting for notarization: $SUBMISSION_ID"
WAIT_STATUS=0
xcrun notarytool wait "$SUBMISSION_ID" "${NOTARY_ARGS[@]}" \
--timeout "${CROSSDESK_MACOS_NOTARY_TIMEOUT:-30m}" \
--output-format json > "$RESULT_FILE" || WAIT_STATUS=$?
cat "$RESULT_FILE"
STATUS="$(plutil -extract status raw -o - "$RESULT_FILE" 2>/dev/null || true)"
xcrun notarytool log "$SUBMISSION_ID" "${NOTARY_ARGS[@]}" "$LOG_FILE" || true
if [[ "$WAIT_STATUS" -ne 0 || "$STATUS" != "Accepted" ]]; then
echo "Notarization did not complete successfully (status: ${STATUS:-unknown})." >&2
echo "Submission: ${SUBMISSION_ID:-unavailable}; response: $RESULT_FILE" >&2
exit 1
fi
xcrun stapler staple "$PACKAGE_FILE"
xcrun stapler validate "$PACKAGE_FILE"
spctl --assess --type install --verbose=2 "$PACKAGE_FILE"
echo "Notarized and stapled: $PACKAGE_FILE"
+38 -2
View File
@@ -13,6 +13,31 @@ IDENTIFIER="cn.crossdesk.app"
ICON_PATH="${PROJECT_ROOT}/apps/desktop/resources/macos/crossdesk.icns"
MACOS_MIN_VERSION="14.0"
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <version>" >&2
exit 2
fi
# For distribution, configure both full Developer ID certificate names.
# Setting CROSSDESK_MACOS_NOTARY_PROFILE also notarizes and staples the pkg.
PRODUCT_SIGN_ARGS=()
if [[ -n "${CROSSDESK_MACOS_INSTALLER_IDENTITY:-}" || -n "${CROSSDESK_MACOS_NOTARY_PROFILE:-}" ||
"${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ]]; then
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:-}" != "Developer ID Application: "* ||
"${CROSSDESK_MACOS_INSTALLER_IDENTITY:-}" != "Developer ID Installer: "* ]]; then
echo "Signed packaging requires CROSSDESK_MACOS_SIGN_IDENTITY and CROSSDESK_MACOS_INSTALLER_IDENTITY." >&2
exit 1
fi
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY##* (}" != "${CROSSDESK_MACOS_INSTALLER_IDENTITY##* (}" ]]; then
echo "Application and installer certificates must belong to the same team." >&2
exit 1
fi
PRODUCT_SIGN_ARGS+=(--sign "$CROSSDESK_MACOS_INSTALLER_IDENTITY" --timestamp)
if [[ -n "${CROSSDESK_MACOS_KEYCHAIN:-}" ]]; then
PRODUCT_SIGN_ARGS+=(--keychain "$CROSSDESK_MACOS_KEYCHAIN")
fi
fi
cd "$PROJECT_ROOT"
normalize_app_version() {
@@ -51,6 +76,8 @@ mkdir -p build_pkg_temp
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
cp "${EXECUTABLE_PATH}" "${MACOS_DIR}/${APP_NAME_UPPER}"
cp "$(dirname "${EXECUTABLE_PATH}")/crossdesk_updater" "${MACOS_DIR}/crossdesk_updater"
chmod +x "${MACOS_DIR}/crossdesk_updater"
chmod +x "${MACOS_DIR}/${APP_NAME_UPPER}"
if [ -f "${ICON_PATH}" ]; then
@@ -173,6 +200,7 @@ cat > build_pkg_temp/Distribution <<EOF
EOF
productbuild \
${PRODUCT_SIGN_ARGS[@]+"${PRODUCT_SIGN_ARGS[@]}"} \
--distribution build_pkg_temp/Distribution \
--package-path build_pkg_temp \
--resources build_pkg_resources \
@@ -180,6 +208,15 @@ productbuild \
echo "PKG package created: ${PKG_NAME}"
if [[ -n "${CROSSDESK_MACOS_INSTALLER_IDENTITY:-}" ]]; then
pkgutil --check-signature "${PKG_NAME}"
fi
if [[ -n "${CROSSDESK_MACOS_NOTARY_PROFILE:-}" ]]; then
bash "${SCRIPT_DIR}/notarize_pkg.sh" "${PKG_NAME}"
elif [[ -n "${CROSSDESK_MACOS_INSTALLER_IDENTITY:-}" ]]; then
echo "Package is signed but not notarized. Set CROSSDESK_MACOS_NOTARY_PROFILE before public distribution."
fi
# Set custom icon for PKG file
if [ -f "${ICON_PATH}" ]; then
echo "Setting custom icon for PKG file..."
@@ -213,8 +250,7 @@ APPLESCRIPT
fi
echo "Set icon finished"
rm -rf build_pkg_temp build_pkg_scripts build_pkg_resources ${APP_BUNDLE}
rm -rf build_pkg_temp build_pkg_scripts build_pkg_resources "${APP_BUNDLE}"
echo "PKG package created successfully."
echo "package ${APP_BUNDLE}"
echo "installer ${PKG_NAME}"
+38 -2
View File
@@ -13,6 +13,31 @@ IDENTIFIER="cn.crossdesk.app"
ICON_PATH="${PROJECT_ROOT}/apps/desktop/resources/macos/crossdesk.icns"
MACOS_MIN_VERSION="14.0"
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <version>" >&2
exit 2
fi
# For distribution, configure both full Developer ID certificate names.
# Setting CROSSDESK_MACOS_NOTARY_PROFILE also notarizes and staples the pkg.
PRODUCT_SIGN_ARGS=()
if [[ -n "${CROSSDESK_MACOS_INSTALLER_IDENTITY:-}" || -n "${CROSSDESK_MACOS_NOTARY_PROFILE:-}" ||
"${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ]]; then
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:-}" != "Developer ID Application: "* ||
"${CROSSDESK_MACOS_INSTALLER_IDENTITY:-}" != "Developer ID Installer: "* ]]; then
echo "Signed packaging requires CROSSDESK_MACOS_SIGN_IDENTITY and CROSSDESK_MACOS_INSTALLER_IDENTITY." >&2
exit 1
fi
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY##* (}" != "${CROSSDESK_MACOS_INSTALLER_IDENTITY##* (}" ]]; then
echo "Application and installer certificates must belong to the same team." >&2
exit 1
fi
PRODUCT_SIGN_ARGS+=(--sign "$CROSSDESK_MACOS_INSTALLER_IDENTITY" --timestamp)
if [[ -n "${CROSSDESK_MACOS_KEYCHAIN:-}" ]]; then
PRODUCT_SIGN_ARGS+=(--keychain "$CROSSDESK_MACOS_KEYCHAIN")
fi
fi
cd "$PROJECT_ROOT"
normalize_app_version() {
@@ -51,6 +76,8 @@ mkdir -p build_pkg_temp
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
cp "${EXECUTABLE_PATH}" "${MACOS_DIR}/${APP_NAME_UPPER}"
cp "$(dirname "${EXECUTABLE_PATH}")/crossdesk_updater" "${MACOS_DIR}/crossdesk_updater"
chmod +x "${MACOS_DIR}/crossdesk_updater"
chmod +x "${MACOS_DIR}/${APP_NAME_UPPER}"
if [ -f "${ICON_PATH}" ]; then
@@ -173,6 +200,7 @@ cat > build_pkg_temp/Distribution <<EOF
EOF
productbuild \
${PRODUCT_SIGN_ARGS[@]+"${PRODUCT_SIGN_ARGS[@]}"} \
--distribution build_pkg_temp/Distribution \
--package-path build_pkg_temp \
--resources build_pkg_resources \
@@ -180,6 +208,15 @@ productbuild \
echo "PKG package created: ${PKG_NAME}"
if [[ -n "${CROSSDESK_MACOS_INSTALLER_IDENTITY:-}" ]]; then
pkgutil --check-signature "${PKG_NAME}"
fi
if [[ -n "${CROSSDESK_MACOS_NOTARY_PROFILE:-}" ]]; then
bash "${SCRIPT_DIR}/notarize_pkg.sh" "${PKG_NAME}"
elif [[ -n "${CROSSDESK_MACOS_INSTALLER_IDENTITY:-}" ]]; then
echo "Package is signed but not notarized. Set CROSSDESK_MACOS_NOTARY_PROFILE before public distribution."
fi
# Set custom icon for PKG file
if [ -f "${ICON_PATH}" ]; then
echo "Setting custom icon for PKG file..."
@@ -213,8 +250,7 @@ APPLESCRIPT
fi
echo "Set icon finished"
rm -rf build_pkg_temp build_pkg_scripts build_pkg_resources ${APP_BUNDLE}
rm -rf build_pkg_temp build_pkg_scripts build_pkg_resources "${APP_BUNDLE}"
echo "PKG package created successfully."
echo "package ${APP_BUNDLE}"
echo "installer ${PKG_NAME}"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <app-bundle>" >&2
echo "Set CROSSDESK_MACOS_SIGN_IDENTITY to a full Developer ID Application name." >&2
echo "Without an identity, the bundle receives an ad-hoc signature for local use." >&2
exit 2
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_BUNDLE="$1"
SIGN_IDENTITY="${CROSSDESK_MACOS_SIGN_IDENTITY:--}"
SIGN_ARGS=(--force --sign "$SIGN_IDENTITY")
APP_ARGS=()
case "$SIGN_IDENTITY" in
-) ;;
"Developer ID Application: "*)
SIGN_ARGS+=(--options runtime --timestamp)
APP_ARGS+=(--entitlements "$SCRIPT_DIR/entitlements.plist")
;;
*)
echo "CROSSDESK_MACOS_SIGN_IDENTITY must be a full Developer ID Application name or '-'." >&2
exit 1
;;
esac
if [[ -n "${CROSSDESK_MACOS_KEYCHAIN:-}" ]]; then
SIGN_ARGS+=(--keychain "$CROSSDESK_MACOS_KEYCHAIN")
fi
for executable in CrossDesk crossdesk_updater; do
if [[ ! -x "$APP_BUNDLE/Contents/MacOS/$executable" ]]; then
echo "Missing app executable: $APP_BUNDLE/Contents/MacOS/$executable" >&2
exit 1
fi
done
if [[ ! -d "$APP_BUNDLE/Contents/Frameworks" ]]; then
echo "Missing bundled frameworks: $APP_BUNDLE/Contents/Frameworks" >&2
exit 1
fi
# Sign nested code explicitly, from the inside out. The updater also runs
# outside the bundle, and must retain its own valid Developer ID signature.
while IFS= read -r -d '' library; do
codesign "${SIGN_ARGS[@]}" "$library"
done < <(find "$APP_BUNDLE/Contents/Frameworks" -type f -name '*.dylib' -print0)
codesign "${SIGN_ARGS[@]}" "$APP_BUNDLE/Contents/MacOS/crossdesk_updater"
codesign "${SIGN_ARGS[@]}" ${APP_ARGS[@]+"${APP_ARGS[@]}"} "$APP_BUNDLE"
codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"
echo "App signed with: $SIGN_IDENTITY"
+52
View File
@@ -71,6 +71,19 @@ verify_dependencies() {
}
verify_dependencies "$APP_EXECUTABLE"
UPDATER="$APP_BUNDLE/Contents/MacOS/crossdesk_updater"
if [[ ! -x "$UPDATER" ]] || ! lipo "$UPDATER" -verify_arch "$EXPECTED_ARCH" >/dev/null 2>&1; then
echo "Missing updater or incorrect updater architecture" >&2
exit 1
fi
# The updater runs outside the bundle during installation, so it must not rely
# on private frameworks that are being replaced.
while IFS= read -r dependency; do
case "$dependency" in
/System/Library/*|/usr/lib/*) ;;
*) echo "Updater has a non-system dynamic dependency: $dependency" >&2; exit 1 ;;
esac
done < <(otool -L "$UPDATER" | tail -n +2 | awk '{ print $1 }')
SLINT_LIBRARY="$(find "$FRAMEWORKS_DIR" -maxdepth 1 -type f -name 'libslint_cpp*.dylib' -print -quit 2>/dev/null || true)"
if [[ -z "$SLINT_LIBRARY" ]]; then
@@ -114,4 +127,43 @@ fi
codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"
# A distribution build must not silently fall back to an ad-hoc/development
# identity, omit a secure timestamp, or leave its updater/runtime unsigned.
APP_SIGNATURE="$(codesign --display --verbose=4 "$APP_BUNDLE" 2>&1)"
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ||
"$APP_SIGNATURE" == *"Authority=Developer ID Application: "* ]]; then
EXPECTED_TEAM="$(printf '%s\n' "$APP_SIGNATURE" | sed -n 's/^TeamIdentifier=//p')"
verify_distribution_signature() {
local target="$1"
local signature=""
codesign --verify --strict --verbose=2 \
-R='anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists' \
"$target"
signature="$(codesign --display --verbose=4 "$target" 2>&1)"
if ! printf '%s\n' "$signature" | grep -q '^CodeDirectory .*flags=.*runtime'; then
echo "Hardened Runtime is missing: $target" >&2
return 1
fi
if ! printf '%s\n' "$signature" | grep -q '^Timestamp='; then
echo "Secure signing timestamp is missing: $target" >&2
return 1
fi
if ! printf '%s\n' "$signature" | grep -Fxq "TeamIdentifier=$EXPECTED_TEAM"; then
echo "Signing team mismatch: $target" >&2
return 1
fi
if [[ "${CROSSDESK_MACOS_SIGN_IDENTITY:--}" != "-" ]] &&
! printf '%s\n' "$signature" | grep -Fxq "Authority=$CROSSDESK_MACOS_SIGN_IDENTITY"; then
echo "Unexpected signing identity: $target" >&2
return 1
fi
}
verify_distribution_signature "$APP_BUNDLE"
verify_distribution_signature "$UPDATER"
while IFS= read -r -d '' library; do
verify_distribution_signature "$library"
done < <(find "$FRAMEWORKS_DIR" -type f -name '*.dylib' -print0)
fi
echo "Verified $APP_BUNDLE ($EXPECTED_ARCH, macOS $BINARY_MIN_VERSION+)"
+29 -1
View File
@@ -39,6 +39,18 @@ RequestExecutionLevel admin
; Include LogicLib for process handling
!include "LogicLib.nsh"
!include "FileFunc.nsh"
Var UpdateMode
Function .onInit
${GetParameters} $0
ClearErrors
${GetOptions} $0 "/CROSSDESK_UPDATE" $1
StrCpy $UpdateMode 0
${IfNot} ${Errors}
StrCpy $UpdateMode 1
${EndIf}
FunctionEnd
Name "${PRODUCT_NAME} ${PRODUCT_VERSION}"
OutFile "crossdesk-win-x64-${PRODUCT_VERSION}.exe"
@@ -55,6 +67,11 @@ InstallDirRegKey HKCU "Software\${PRODUCT_NAME}" "InstallDir"
ShowInstDetails show
Section "MainSection"
; The updater has already coordinated shutdown. Never kill unrelated
; instances by name during an unattended upgrade.
${If} $UpdateMode = 1
Goto installApp
${EndIf}
; Check if CrossDesk is running
StrCpy $1 "CrossDesk.exe"
@@ -81,10 +98,12 @@ installApp:
Call StopInstalledService
SetOutPath "$INSTDIR"
SetOverwrite ifnewer
; The signed release determines file versions, not filesystem timestamps.
SetOverwrite on
; Main application executable path
File /oname=CrossDesk.exe "..\..\..\..\build\windows\x64\release\crossdesk.exe"
File "..\..\..\..\build\windows\x64\release\crossdesk_updater.exe"
; Bundle service-side binaries required by the Windows service flow
File "..\..\..\..\build\windows\x64\release\crossdesk_service.exe"
File "..\..\..\..\build\windows\x64\release\crossdesk_session_helper.exe"
@@ -116,6 +135,7 @@ installApp:
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayName" "${PRODUCT_NAME}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "UninstallString" "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayVersion" "${PRODUCT_VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "Publisher" "${PRODUCT_PUBLISHER}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "URLInfoAbout" "${PRODUCT_WEB_SITE}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayIcon" "$INSTDIR\CrossDesk.exe"
@@ -167,6 +187,7 @@ uninstallApp:
; Delete main executable and uninstaller
Delete "$INSTDIR\CrossDesk.exe"
Delete "$INSTDIR\crossdesk_updater.exe"
Delete "$INSTDIR\crossdesk_service.exe"
Delete "$INSTDIR\crossdesk_session_helper.exe"
Delete "$INSTDIR\crossdesk_privacy_cursor_helper.exe"
@@ -196,9 +217,16 @@ SectionEnd
; ------ Functions ------
Function LaunchApp
${If} $UpdateMode = 1
Return
${EndIf}
Exec "$INSTDIR\CrossDesk.exe"
FunctionEnd
Function .onInstFailed
SetErrorLevel 1
FunctionEnd
Function StopInstalledService
IfFileExists "$INSTDIR\CrossDesk.exe" 0 stop_with_sc
IfFileExists "$INSTDIR\crossdesk_service.exe" 0 stop_with_sc
+54 -5
View File
@@ -20,15 +20,20 @@
#include "platform/windows/gui/slint_backend.h"
#include "service_host.h"
#include "service_lifetime.h"
#include "shutdown_order.h"
#include "unattended_config.h"
#endif
#include "config_center.h"
#include "daemon.h"
#include "path_manager.h"
#include "platform/instance_activation.h"
#include "platform/single_instance.h"
#include "rd_log.h"
#include "render.h"
#if CROSSDESK_HAS_UPDATER
#include "updater/update_platform.h"
#endif
#ifdef __linux__
#include "SimpleIni.h"
@@ -203,8 +208,6 @@ int HandleServiceCliCommand(const std::string& command) {
destination.SetTurnMode(source.GetTurnMode()) == 0 &&
destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) ==
0 &&
destination.SetHardwareVideoCodec(source.IsHardwareVideoCodec()) ==
0 &&
destination.SetScreenCaptureMethod(
crossdesk::ScreenCaptureMethod::Auto) == 0 &&
destination.SetPrivacyScreen(false) == 0 &&
@@ -307,6 +310,25 @@ static crossdesk::platform::InstanceResult AcquireInstance(
return result;
}
static int ExitForExistingInstance(
crossdesk::platform::InstanceResult result, bool interactive_launch) {
if (interactive_launch &&
result == crossdesk::platform::InstanceResult::kAlreadyRunning) {
std::string error;
if (!crossdesk::platform::RequestInstanceActivation(error)) {
std::cerr << error << '\n';
#ifdef _WIN32
MessageBoxW(nullptr,
L"CrossDesk is already running, but its window could not be "
L"activated.\n\nIt may still be starting or be running in "
L"another Windows session. Please check the system tray.",
L"CrossDesk", MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND);
#endif
}
}
return result == crossdesk::platform::InstanceResult::kAlreadyRunning ? 0 : 1;
}
static int RunApplication(
int argc, char* argv[],
crossdesk::platform::SingleInstanceGuard& client_instance) {
@@ -314,6 +336,12 @@ static int RunApplication(
auto path_manager = std::make_unique<crossdesk::PathManager>("CrossDesk");
crossdesk::InitLogger(path_manager->GetLogPath().string());
#ifdef _WIN32
crossdesk::get_logger()->info(
"CrossDesk executable: {}",
std::filesystem::path(GetCurrentExecutablePathW()).u8string());
#endif
if (IsDaemonChild(argc, argv)) {
// child process: run render directly
crossdesk::Render render;
@@ -377,6 +405,16 @@ static int RunApplication(
}
int main(int argc, char* argv[]) {
#ifdef _WIN32
// Apply in every process, including the daemon and --child. Windows does
// not inherit shutdown priority from a parent process.
const DWORD shutdown_error = crossdesk::ConfigureWindowsShutdownOrder(
crossdesk::WindowsShutdownRole::host);
if (shutdown_error != ERROR_SUCCESS) {
std::cerr << "Could not configure Windows host shutdown order, error="
<< shutdown_error << '\n';
}
#endif
#ifdef __linux__
// Run before GUI, configuration, logging or display initialization.
if (argc == 2 &&
@@ -389,7 +427,7 @@ int main(int argc, char* argv[]) {
CSimpleIniA config;
config.SetUnicode(true);
config.LoadFile((paths.GetCachePath() / "config.ini").string().c_str());
console.SetLanguage(static_cast<int>(config.GetLongValue("Settings", "language", 0)));
console.SetLanguage(static_cast<int>(config.GetLongValue("Settings", "language", 10)));
std::cout << console.Text("console_cli_help");
return 0;
}
@@ -446,17 +484,28 @@ int main(int argc, char* argv[]) {
#endif
using crossdesk::platform::InstanceResult;
#if CROSSDESK_HAS_UPDATER
// Installer CLI commands above stay available during maintenance. Ordinary
// launches (including daemon restarts) must not reopen the installation.
try {
if (crossdesk::updater::MaintenanceActive(crossdesk::updater::ExecutablePath()))
return 0;
} catch (const std::exception& error) {
std::cerr << "Cannot check update maintenance: " << error.what() << '\n';
return 1;
}
#endif
using crossdesk::platform::InstanceRole;
crossdesk::platform::SingleInstanceGuard launcher_instance;
crossdesk::platform::SingleInstanceGuard client_instance;
if (!IsDaemonChild(argc, argv)) {
const auto result = AcquireInstance(launcher_instance, InstanceRole::kLauncher);
if (result != InstanceResult::kAcquired)
return result == InstanceResult::kAlreadyRunning ? 0 : 1;
return ExitForExistingInstance(result, true);
}
const auto result = AcquireInstance(client_instance, InstanceRole::kClient);
if (result != InstanceResult::kAcquired)
return result == InstanceResult::kAlreadyRunning ? 0 : 1;
return ExitForExistingInstance(result, !IsDaemonChild(argc, argv));
#ifdef __linux__
const int exit_code = crossdesk::RunWithLinuxDisplay(

Some files were not shown because too many files have changed in this diff Show More