From 8861d4b7f2f17b38c58e39e46c75ca925289acf5 Mon Sep 17 00:00:00 2001 From: springmin Date: Sun, 18 Oct 2026 22:06:12 +0800 Subject: [PATCH 01/45] ohos: add the OpenHarmony platform host Tier-3 community port (maintainer: springmin), rebased onto the merged document-provider abstraction (#84) and the opener seam (#80). - platform/ohos: Slint backend (XComponent window adapter, software renderer with the experimental femtovg/wgpu path, input/IME, clipboard, system fonts), the NAPI cdylib that builds a PlatformContext (app-private paths, DocumentProvider over the ArkTS pickers, Asset Store credential store, loopback OAuth broker, external URL opener, notification publisher) and calls flectar_mail::run, the demo crate, the ArkTS shell and the build and packaging scripts (bundle name configurable, defaults to com.flectar.mail_ohos). - src/platform/ohos.rs: PDFium directory, renderer name, thread priority, preview lifecycle, email font directory; OpenHarmony stays under the mobile capability alias. - patches/fontique + crates/fontique: the Fontconfig opt-out as a patch queue applied by the platform/ohos workspace, verified from quality.yml. - .github/workflows/ohos.yml: manually dispatched tier-3 workflow, unsigned by default, signing material only from the FLECTAR_OHOS_* secrets. - src/documents.rs: run_request blocks the calling thread on mobile hosts; the picker covers the window, and keeping the presentation thread idle is what OpenHarmony needs (verified on device; desktop keeps the runtime request for the native dialog). - The email renderer and the compose editor load platform::email_font_dir() and the Han fallback, so CJK mail bodies and composed text render with the platform fonts. - New-mail notifications: a publisher seam fed by CoreEvent::MailNew; the host publishes through @kit.NotificationKit. --- .github/workflows/ohos.yml | 104 + .github/workflows/quality.yml | 2 + .gitignore | 3 + Cargo.toml | 4 +- build.rs | 2 +- crates/flectar-mail-core/build.rs | 2 +- crates/fontique/.cargo_vcs_info.json | 6 + crates/fontique/Cargo.lock | 388 + crates/fontique/Cargo.toml | 193 + crates/fontique/Cargo.toml.orig | 79 + crates/fontique/LICENSE-APACHE | 201 + crates/fontique/LICENSE-MIT | 25 + crates/fontique/README.md | 54 + crates/fontique/examples/generic_families.rs | 37 + crates/fontique/src/attributes.rs | 42 + crates/fontique/src/backend/android.rs | 221 + crates/fontique/src/backend/coretext.rs | 207 + crates/fontique/src/backend/dwrite.rs | 397 + crates/fontique/src/backend/fontconfig.rs | 838 ++ crates/fontique/src/backend/mod.rs | 82 + crates/fontique/src/charmap.rs | 127 + crates/fontique/src/collection/mod.rs | 877 ++ crates/fontique/src/collection/query.rs | 316 + crates/fontique/src/fallback.rs | 293 + crates/fontique/src/family.rs | 111 + crates/fontique/src/family_name.rs | 119 + crates/fontique/src/font.rs | 480 + crates/fontique/src/generic.rs | 36 + crates/fontique/src/impl_fontconfig.rs | 159 + crates/fontique/src/lib.rs | 82 + crates/fontique/src/matching.rs | 464 + crates/fontique/src/scan.rs | 312 + crates/fontique/src/script.rs | 289 + crates/fontique/src/source.rs | 108 + crates/fontique/src/source_cache.rs | 254 + .../fontique/0001-ohos-no-fontconfig.patch | 60 + patches/fontique/README.md | 27 + patches/fontique/series | 1 + patches/fontique/upstream.toml | 4 + platform/ohos/.gitignore | 15 + .../ohos/.hvigor/outputs/build-logs/build.log | 0 platform/ohos/Cargo.lock | 8596 +++++++++++++++++ platform/ohos/Cargo.toml | 38 + platform/ohos/README.md | 148 + platform/ohos/backend/Cargo.toml | 30 + platform/ohos/backend/build.rs | 32 + platform/ohos/backend/src/asset.rs | 191 + platform/ohos/backend/src/clipboard.rs | 95 + platform/ohos/backend/src/display.rs | 17 + platform/ohos/backend/src/event.rs | 101 + platform/ohos/backend/src/ffi.rs | 544 ++ platform/ohos/backend/src/fonts.rs | 83 + platform/ohos/backend/src/gpu.rs | 151 + platform/ohos/backend/src/ime.rs | 76 + platform/ohos/backend/src/lib.rs | 87 + platform/ohos/backend/src/log.rs | 63 + platform/ohos/backend/src/perf.rs | 68 + platform/ohos/backend/src/platform.rs | 260 + platform/ohos/backend/src/present.rs | 353 + platform/ohos/backend/src/window.rs | 301 + platform/ohos/backend/src/xcomponent.rs | 359 + platform/ohos/demo/Cargo.toml | 24 + platform/ohos/demo/build.rs | 3 + platform/ohos/demo/src/lib.rs | 100 + platform/ohos/demo/ui/demo.slint | 67 + platform/ohos/mail/Cargo.toml | 21 + platform/ohos/mail/src/bridge.rs | 559 ++ platform/ohos/mail/src/documents.rs | 45 + platform/ohos/mail/src/lib.rs | 450 + platform/ohos/scripts/build-hap.sh | 343 + platform/ohos/scripts/env.sh | 34 + platform/ohos/scripts/fetch-pdfium.sh | 50 + platform/ohos/scripts/run-device.sh | 31 + platform/ohos/shell/AppScope/app.json5 | 11 + .../resources/base/element/string.json | 5 + .../resources/base/media/app_icon.png | Bin 0 -> 16334 bytes platform/ohos/shell/build-profile.json5 | 23 + platform/ohos/shell/entry/build-profile.json5 | 5 + platform/ohos/shell/entry/hvigorfile.ts | 1 + platform/ohos/shell/entry/oh-package.json5 | 9 + .../main/ets/entryability/EntryAbility.ets | 15 + .../shell/entry/src/main/ets/pages/Index.ets | 347 + .../ohos/shell/entry/src/main/module.json5 | 31 + .../main/resources/base/element/color.json | 5 + .../main/resources/base/element/string.json | 7 + .../main/resources/base/media/app_icon.png | Bin 0 -> 16334 bytes .../resources/base/profile/main_pages.json | 1 + .../ohos/shell/hvigor/hvigor-config.json5 | 7 + platform/ohos/shell/hvigorfile.ts | 1 + platform/ohos/shell/oh-package.json5 | 10 + .../ohos/shell/page-templates/Index.demo.ets | 19 + .../ohos/shell/page-templates/Index.mail.ets | 347 + scripts/fontique-patches.sh | 77 + src/compose_editor.rs | 7 + src/documents.rs | 13 + src/mail_setup.rs | 39 +- src/main.rs | 10 +- src/notifications.rs | 91 + src/platform/android.rs | 5 + src/platform/ios.rs | 5 + src/platform/mod.rs | 11 + src/platform/ohos.rs | 58 + src/renderer.rs | 38 + src/startup.rs | 8 + 104 files changed, 21536 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ohos.yml create mode 100644 crates/fontique/.cargo_vcs_info.json create mode 100644 crates/fontique/Cargo.lock create mode 100644 crates/fontique/Cargo.toml create mode 100644 crates/fontique/Cargo.toml.orig create mode 100644 crates/fontique/LICENSE-APACHE create mode 100644 crates/fontique/LICENSE-MIT create mode 100644 crates/fontique/README.md create mode 100644 crates/fontique/examples/generic_families.rs create mode 100644 crates/fontique/src/attributes.rs create mode 100644 crates/fontique/src/backend/android.rs create mode 100644 crates/fontique/src/backend/coretext.rs create mode 100644 crates/fontique/src/backend/dwrite.rs create mode 100644 crates/fontique/src/backend/fontconfig.rs create mode 100644 crates/fontique/src/backend/mod.rs create mode 100644 crates/fontique/src/charmap.rs create mode 100644 crates/fontique/src/collection/mod.rs create mode 100644 crates/fontique/src/collection/query.rs create mode 100644 crates/fontique/src/fallback.rs create mode 100644 crates/fontique/src/family.rs create mode 100644 crates/fontique/src/family_name.rs create mode 100644 crates/fontique/src/font.rs create mode 100644 crates/fontique/src/generic.rs create mode 100644 crates/fontique/src/impl_fontconfig.rs create mode 100644 crates/fontique/src/lib.rs create mode 100644 crates/fontique/src/matching.rs create mode 100644 crates/fontique/src/scan.rs create mode 100644 crates/fontique/src/script.rs create mode 100644 crates/fontique/src/source.rs create mode 100644 crates/fontique/src/source_cache.rs create mode 100644 patches/fontique/0001-ohos-no-fontconfig.patch create mode 100644 patches/fontique/README.md create mode 100644 patches/fontique/series create mode 100644 patches/fontique/upstream.toml create mode 100644 platform/ohos/.gitignore create mode 100644 platform/ohos/.hvigor/outputs/build-logs/build.log create mode 100644 platform/ohos/Cargo.lock create mode 100644 platform/ohos/Cargo.toml create mode 100644 platform/ohos/README.md create mode 100644 platform/ohos/backend/Cargo.toml create mode 100644 platform/ohos/backend/build.rs create mode 100644 platform/ohos/backend/src/asset.rs create mode 100644 platform/ohos/backend/src/clipboard.rs create mode 100644 platform/ohos/backend/src/display.rs create mode 100644 platform/ohos/backend/src/event.rs create mode 100644 platform/ohos/backend/src/ffi.rs create mode 100644 platform/ohos/backend/src/fonts.rs create mode 100644 platform/ohos/backend/src/gpu.rs create mode 100644 platform/ohos/backend/src/ime.rs create mode 100644 platform/ohos/backend/src/lib.rs create mode 100644 platform/ohos/backend/src/log.rs create mode 100644 platform/ohos/backend/src/perf.rs create mode 100644 platform/ohos/backend/src/platform.rs create mode 100644 platform/ohos/backend/src/present.rs create mode 100644 platform/ohos/backend/src/window.rs create mode 100644 platform/ohos/backend/src/xcomponent.rs create mode 100644 platform/ohos/demo/Cargo.toml create mode 100644 platform/ohos/demo/build.rs create mode 100644 platform/ohos/demo/src/lib.rs create mode 100644 platform/ohos/demo/ui/demo.slint create mode 100644 platform/ohos/mail/Cargo.toml create mode 100644 platform/ohos/mail/src/bridge.rs create mode 100644 platform/ohos/mail/src/documents.rs create mode 100644 platform/ohos/mail/src/lib.rs create mode 100755 platform/ohos/scripts/build-hap.sh create mode 100755 platform/ohos/scripts/env.sh create mode 100755 platform/ohos/scripts/fetch-pdfium.sh create mode 100755 platform/ohos/scripts/run-device.sh create mode 100644 platform/ohos/shell/AppScope/app.json5 create mode 100644 platform/ohos/shell/AppScope/resources/base/element/string.json create mode 100644 platform/ohos/shell/AppScope/resources/base/media/app_icon.png create mode 100644 platform/ohos/shell/build-profile.json5 create mode 100644 platform/ohos/shell/entry/build-profile.json5 create mode 100644 platform/ohos/shell/entry/hvigorfile.ts create mode 100644 platform/ohos/shell/entry/oh-package.json5 create mode 100644 platform/ohos/shell/entry/src/main/ets/entryability/EntryAbility.ets create mode 100644 platform/ohos/shell/entry/src/main/ets/pages/Index.ets create mode 100644 platform/ohos/shell/entry/src/main/module.json5 create mode 100644 platform/ohos/shell/entry/src/main/resources/base/element/color.json create mode 100644 platform/ohos/shell/entry/src/main/resources/base/element/string.json create mode 100644 platform/ohos/shell/entry/src/main/resources/base/media/app_icon.png create mode 100644 platform/ohos/shell/entry/src/main/resources/base/profile/main_pages.json create mode 100644 platform/ohos/shell/hvigor/hvigor-config.json5 create mode 100644 platform/ohos/shell/hvigorfile.ts create mode 100644 platform/ohos/shell/oh-package.json5 create mode 100644 platform/ohos/shell/page-templates/Index.demo.ets create mode 100644 platform/ohos/shell/page-templates/Index.mail.ets create mode 100755 scripts/fontique-patches.sh create mode 100644 src/notifications.rs create mode 100644 src/platform/ohos.rs diff --git a/.github/workflows/ohos.yml b/.github/workflows/ohos.yml new file mode 100644 index 00000000..0841b85c --- /dev/null +++ b/.github/workflows/ohos.yml @@ -0,0 +1,104 @@ +name: OpenHarmony hap + +# Tier 3 (platform/README.md): a community port built by a manually dispatched +# workflow, never on pull requests. The HarmonyOS SDK and hvigor are not +# anonymously downloadable, so the build job targets a self-hosted runner with +# the toolchain described in platform/ohos/README.md. +# +# Pull request checks build unsigned: this workflow only runs on dispatch, and +# signing material exists exclusively as repository secrets. + +on: + workflow_dispatch: + inputs: + app: + description: Application to package + type: choice + options: + - mail + - demo + default: mail + profile: + description: Cargo profile + type: choice + options: + - release + - debug + default: release + sign: + description: Sign with the AGC material stored in the repository secrets + type: boolean + default: false + run_quality: + description: Run the shared quality suite first + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: ohos-${{ github.ref }} + cancel-in-progress: false + +jobs: + quality: + if: inputs.run_quality + name: Shared quality suite + uses: ./.github/workflows/quality.yml + + hap: + name: ${{ inputs.app }} hap (${{ inputs.profile }}) + needs: quality + if: always() && (needs.quality.result == 'success' || needs.quality.result == 'skipped') + runs-on: [self-hosted, linux, ohos] + timeout-minutes: 180 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Check the OHOS workspace formatting + run: cargo fmt --manifest-path platform/ohos/Cargo.toml --all -- --check + + - name: Materialize signing material + if: inputs.sign + env: + KEYSTORE_BASE64: ${{ secrets.FLECTAR_OHOS_KEYSTORE_BASE64 }} + CERTIFICATE_BASE64: ${{ secrets.FLECTAR_OHOS_CERTIFICATE_BASE64 }} + PROFILE_BASE64: ${{ secrets.FLECTAR_OHOS_PROFILE_BASE64 }} + run: | + test -n "$KEYSTORE_BASE64" + test -n "$CERTIFICATE_BASE64" + test -n "$PROFILE_BASE64" + material="$RUNNER_TEMP/ohos-signing" + umask 077 + mkdir -p "$material" + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$material/keystore.p12" + printf '%s' "$CERTIFICATE_BASE64" | base64 --decode > "$material/certificate.cer" + printf '%s' "$PROFILE_BASE64" | base64 --decode > "$material/profile.p7b" + { + echo "RELEASE_SIGN=1" + echo "RELEASE_P12=$material/keystore.p12" + echo "RELEASE_CERT=$material/certificate.cer" + echo "RELEASE_PROFILE=$material/profile.p7b" + echo "RELEASE_KEY_ALIAS=${{ secrets.FLECTAR_OHOS_KEY_ALIAS }}" + echo "RELEASE_KEY_PWD=${{ secrets.FLECTAR_OHOS_KEY_PASSWORD }}" + echo "RELEASE_STORE_PWD=${{ secrets.FLECTAR_OHOS_STORE_PASSWORD }}" + } >> "$GITHUB_ENV" + + - name: Build the hap + env: + APP: ${{ inputs.app }} + PROFILE: ${{ inputs.profile }} + CARGO_BUILD_JOBS: "4" + SKIP_SIGN: ${{ inputs.sign && '0' || '1' }} + run: sh platform/ohos/scripts/build-hap.sh + + - name: Remove the signing material + if: always() && inputs.sign + run: rm -rf "$RUNNER_TEMP/ohos-signing" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: flectar-ohos-${{ inputs.app }}-${{ inputs.profile }} + path: platform/ohos/build/*.hap + if-no-files-found: error diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a480d862..4e289d63 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -83,6 +83,8 @@ jobs: run: python3 scripts/check-platform-gates.py - name: Verify the Blitz patch queue run: bash scripts/blitz-patches.sh verify + - name: Verify the Fontique patch queue + run: bash scripts/fontique-patches.sh verify - name: Verify and test the Parley backport run: | python3 scripts/verify-parley.py diff --git a/.gitignore b/.gitignore index eecc0555..ab0c058a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ # graft's local graph cache — regenerable, not committed (run `graft build`). /graft/ + +# hvigor build metadata (the OHOS shell build regenerates it) +.hvigor/ diff --git a/Cargo.toml b/Cargo.toml index 974eea8e..1aacf8ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = ["crates/flectar-mail-core", "crates/renderer-probe"] -exclude = ["crates/candle-core", "crates/blitz-dom", "crates/blitz-paint", "crates/parley", "crates/parley_emoji"] +exclude = ["crates/candle-core", "crates/blitz-dom", "crates/blitz-paint", "crates/fontique", "crates/parley", "crates/parley_emoji"] resolver = "3" [workspace.dependencies] @@ -171,7 +171,7 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std objc2-service-management = { version = "0.3", default-features = false, features = ["std", "objc2", "objc2-foundation", "SMAppService"] } # This is `desktop`; keep it in step with the `mobile` alias in build.rs. -[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +[target.'cfg(not(any(target_os = "android", target_os = "ios", target_env = "ohos")))'.dependencies] arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } rfd = { version = "0.16", default-features = false, features = ["tokio", "xdg-portal"] } diff --git a/build.rs b/build.rs index c4738b59..238af5c4 100644 --- a/build.rs +++ b/build.rs @@ -5,7 +5,7 @@ fn main() { // operating system. A new platform joins these groups here (and in the // core's build.rs) instead of editing call sites. See platform/README.md. cfg_aliases! { - mobile: { any(target_os = "android", target_os = "ios") }, + mobile: { any(target_os = "android", target_os = "ios", target_env = "ohos") }, desktop: { not(mobile) }, // OpenHarmony reports target_os = "linux" without a desktop session, // portals, X11 or a Secret Service. diff --git a/crates/flectar-mail-core/build.rs b/crates/flectar-mail-core/build.rs index 65fe4c03..657193cc 100644 --- a/crates/flectar-mail-core/build.rs +++ b/crates/flectar-mail-core/build.rs @@ -12,7 +12,7 @@ fn main() { // Keep `mobile` identical to the application's build.rs; see // platform/README.md. cfg_aliases! { - mobile: { any(target_os = "android", target_os = "ios") }, + mobile: { any(target_os = "android", target_os = "ios", target_env = "ohos") }, desktop: { not(mobile) }, // rustix exposes renameat2/renameatx_np with RENAME_NOREPLACE here. rename_noreplace: { any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "ios") }, diff --git a/crates/fontique/.cargo_vcs_info.json b/crates/fontique/.cargo_vcs_info.json new file mode 100644 index 00000000..df2a1335 --- /dev/null +++ b/crates/fontique/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "eea3503dd6cf17130cbb07348e0ff2c918300e94" + }, + "path_in_vcs": "fontique" +} \ No newline at end of file diff --git a/crates/fontique/Cargo.lock b/crates/fontique/Cargo.lock new file mode 100644 index 00000000..9bf1228c --- /dev/null +++ b/crates/fontique/Cargo.lock @@ -0,0 +1,388 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bf886368962a7d8456f136073ed33ed1b0770c690d2063731bb6a776e99f33" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.11.1" +dependencies = [ + "hashbrown", + "linebender_resource_handle", + "memmap2", + "objc2", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation", + "parlance", + "read-fonts", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parlance" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "core_maths", + "font-types", + "once_cell", +] + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] diff --git a/crates/fontique/Cargo.toml b/crates/fontique/Cargo.toml new file mode 100644 index 00000000..39c9c500 --- /dev/null +++ b/crates/fontique/Cargo.toml @@ -0,0 +1,193 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.88" +name = "fontique" +version = "0.11.1" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Font enumeration and fallback." +readme = "README.md" +keywords = [ + "font", + "text", +] +categories = [ + "gui", + "os", +] +license = "Apache-2.0 OR MIT" +repository = "https://github.com/linebender/parley" + +[package.metadata.docs.rs] +all-features = true + +[features] +bytemuck = ["parlance/bytemuck"] +default = ["system"] +fontconfig-dlopen = ["yeslogic-fontconfig-sys?/dlopen"] +libm = ["read-fonts/libm"] +std = [ + "read-fonts/std", + "dep:memmap2", + "parlance/std", +] +system = [ + "std", + "dep:windows", + "dep:windows-core", + "dep:objc2", + "dep:objc2-core-foundation", + "dep:objc2-core-text", + "dep:objc2-foundation", + "dep:yeslogic-fontconfig-sys", + "dep:roxmltree", +] + +[lib] +name = "fontique" +path = "src/lib.rs" + +[[example]] +name = "generic_families" +path = "examples/generic_families.rs" + +[dependencies.hashbrown] +version = "0.17.0" +features = [ + "default-hasher", + "raw-entry", +] +default-features = false + +[dependencies.linebender_resource_handle] +version = "0.1.1" +default-features = false + +[dependencies.memmap2] +version = "0.9.10" +optional = true + +[dependencies.parlance] +version = "0.1.0" +default-features = false + +[dependencies.read-fonts] +version = "0.41.0" +default-features = false + +[dependencies.smallvec] +version = "1.15.1" + +[target.'cfg(all(any(target_os = "linux", target_os="freebsd"), not(target_env = "ohos")))'.dependencies.yeslogic-fontconfig-sys] +version = "6.0.0" +optional = true + +[target.'cfg(target_os = "android")'.dependencies.roxmltree] +version = "0.21.1" +optional = true + +[target.'cfg(target_os = "windows")'.dependencies.windows] +version = "0.62.2" +features = ["Win32_Graphics_DirectWrite"] +optional = true + +[target.'cfg(target_os = "windows")'.dependencies.windows-core] +version = "0.62.2" +optional = true + +[target.'cfg(target_vendor = "apple")'.dependencies.objc2] +version = "0.6.4" +features = [ + "std", + "relax-sign-encoding", +] +optional = true + +[target.'cfg(target_vendor = "apple")'.dependencies.objc2-core-foundation] +version = "0.3.2" +features = [ + "CFBase", + "CFURL", +] +optional = true +default-features = false + +[target.'cfg(target_vendor = "apple")'.dependencies.objc2-core-text] +version = "0.3.2" +features = [ + "CTFont", + "CTFontDescriptor", + "CTFontCollection", +] +optional = true +default-features = false + +[target.'cfg(target_vendor = "apple")'.dependencies.objc2-foundation] +version = "0.3.2" +features = [ + "alloc", + "NSArray", + "NSEnumerator", + "NSPathUtilities", + "NSString", +] +optional = true +default-features = false + +[lints.clippy] +allow_attributes_without_reason = "warn" +cargo_common_metadata = "warn" +cast_possible_truncation = "warn" +collection_is_never_read = "warn" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +default_trait_access = "warn" +doc_markdown = "warn" +fn_to_numeric_cast_any = "warn" +infinite_loop = "warn" +large_stack_arrays = "warn" +mismatching_type_param_order = "warn" +missing_assert_message = "warn" +missing_fields_in_debug = "warn" +negative_feature_names = "warn" +redundant_feature_names = "warn" +same_functions_in_if_condition = "warn" +semicolon_if_nothing_returned = "warn" +should_panic_without_expect = "warn" +todo = "warn" +too_many_arguments = "allow" +unseparated_literal_suffix = "warn" +use_self = "warn" +wildcard_dependencies = "warn" + +[lints.rust] +elided_lifetimes_in_paths = "warn" +keyword_idents_2024 = "forbid" +missing_debug_implementations = "warn" +missing_docs = "warn" +non_ascii_idents = "forbid" +non_local_definitions = "forbid" +trivial_numeric_casts = "warn" +unnameable_types = "warn" +unreachable_pub = "warn" +unsafe_code = "deny" +unsafe_op_in_unsafe_fn = "forbid" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_macro_rules = "warn" +unused_qualifications = "warn" diff --git a/crates/fontique/Cargo.toml.orig b/crates/fontique/Cargo.toml.orig new file mode 100644 index 00000000..91c4d818 --- /dev/null +++ b/crates/fontique/Cargo.toml.orig @@ -0,0 +1,79 @@ +[package] +name = "fontique" +version.workspace = true +description = "Font enumeration and fallback." +keywords = ["font", "text"] +categories = ["gui", "os"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true + +[features] +default = ["system"] +std = ["read-fonts/std", "dep:memmap2", "parlance/std"] +libm = ["read-fonts/libm"] +bytemuck = ["parlance/bytemuck"] +# Enables support for system font backends +system = [ + "std", + "dep:windows", + "dep:windows-core", + "dep:objc2", + "dep:objc2-core-foundation", + "dep:objc2-core-text", + "dep:objc2-foundation", + "dep:yeslogic-fontconfig-sys", + "dep:roxmltree", +] + +# Use dlopen to load the fontconfig library. This allows Fontique to compile even if the +# system does not have fontconfig installed, although the font fallback to system fonts +# will not work properly in this case (no such fallback will occur) +fontconfig-dlopen = ["yeslogic-fontconfig-sys?/dlopen"] + +[dependencies] +read-fonts = { workspace = true } +linebender_resource_handle = { workspace = true } +smallvec = "1.15.1" +memmap2 = { version = "0.9.10", optional = true } +hashbrown = { workspace = true } +parlance = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.62.2", features = ["Win32_Graphics_DirectWrite"], optional = true } +windows-core = { version = "0.62.2", optional = true } + +[target.'cfg(target_vendor = "apple")'.dependencies] +# FIX: Enable relax-sign-encoding to prevent the bug described in this issue: https://github.com/madsmtm/objc2/issues/566 +objc2 = { version = "0.6.4", optional = true, features = ["std", "relax-sign-encoding"] } +# NOTE: When updating objc2-foundation, objc2-core-foundation, or objc2-core-text make sure to use the version of objc2 +# that they reference to prevent crate duplication. +objc2-foundation = { version = "0.3.2", optional = true, default-features = false, features = [ + "alloc", + "NSArray", + "NSEnumerator", + "NSPathUtilities", + "NSString", +] } +objc2-core-foundation = { version = "0.3.2", optional = true, default-features = false, features = [ + "CFBase", + "CFURL", +] } +objc2-core-text = { version = "0.3.2", optional = true, default-features = false, features = [ + "CTFont", + "CTFontDescriptor", + "CTFontCollection", +] } + +[target.'cfg(any(target_os = "linux", target_os="freebsd"))'.dependencies] +yeslogic-fontconfig-sys = { version = "6.0.0", optional = true } + +[target.'cfg(target_os = "android")'.dependencies] +roxmltree = { version = "0.21.1", optional = true } diff --git a/crates/fontique/LICENSE-APACHE b/crates/fontique/LICENSE-APACHE new file mode 100644 index 00000000..16fe87b0 --- /dev/null +++ b/crates/fontique/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/crates/fontique/LICENSE-MIT b/crates/fontique/LICENSE-MIT new file mode 100644 index 00000000..6601faa0 --- /dev/null +++ b/crates/fontique/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright 2024 the Parley Authors + +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/fontique/README.md b/crates/fontique/README.md new file mode 100644 index 00000000..6c66e142 --- /dev/null +++ b/crates/fontique/README.md @@ -0,0 +1,54 @@ +
+ +# Fontique + +**Font enumeration and fallback** + +[![Latest published fontique version.](https://img.shields.io/crates/v/fontique.svg)](https://crates.io/crates/fontique) +[![Documentation build status.](https://img.shields.io/docsrs/fontique.svg)](https://docs.rs/fontique) +[![Dependency staleness status.](https://deps.rs/crate/fontique/latest/status.svg)](https://deps.rs/crate/fontique) +[![Linebender Zulip chat.](https://img.shields.io/badge/Linebender-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley) +[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) + +
+ +Fontique provides font enumeration and fallback. + +## Minimum supported Rust Version (MSRV) + +This version of Fontique has been verified to compile with **Rust 1.88** and later. + +Future versions of Fontique might increase the Rust version requirement. +It will not be treated as a breaking change and as such can even happen with small patch releases. + +
+Click here if compiling fails. + +As time has passed, some of Fontique's dependencies could have released versions with a higher Rust requirement. +If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency. + +```sh +# Use the problematic dependency's name and version +cargo update -p package_name --precise 0.1.1 +``` +
+ +## Community + +Discussion of Fontique development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#parley channel](https://xi.zulipchat.com/#narrow/channel/205635-parley). +All public content can be read without logging in. + +Contributions are welcome by pull request. The [Rust code of conduct] applies. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache 2.0 license, shall be licensed as noted in the [License](#license) section, without any additional terms or conditions. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +[Rust code of conduct]: https://www.rust-lang.org/policies/code-of-conduct diff --git a/crates/fontique/examples/generic_families.rs b/crates/fontique/examples/generic_families.rs new file mode 100644 index 00000000..7d47a37c --- /dev/null +++ b/crates/fontique/examples/generic_families.rs @@ -0,0 +1,37 @@ +// Copyright 2024 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Print an enumeration of discovered fonts for each `GenericFamily`. + +use fontique::{Collection, CollectionOptions, GenericFamily::*}; + +fn main() { + let mut collection = Collection::new(CollectionOptions::default()); + + for gf in [ + Serif, + SansSerif, + Monospace, + Cursive, + Fantasy, + SystemUi, + UiSerif, + UiSansSerif, + UiMonospace, + UiRounded, + Emoji, + Math, + FangSong, + ] { + println!("GenericFamily::{gf:?}:"); + + let ids = collection.generic_families(gf).collect::>(); + for id in ids { + if let Some(name) = collection.family_name(id) { + println!("{name}"); + } + } + + println!(); + } +} diff --git a/crates/fontique/src/attributes.rs b/crates/fontique/src/attributes.rs new file mode 100644 index 00000000..5cbc699d --- /dev/null +++ b/crates/fontique/src/attributes.rs @@ -0,0 +1,42 @@ +// Copyright 2024 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Properties for specifying font matching attributes. + +use core::fmt; + +use parlance::{FontStyle, FontWeight, FontWidth}; + +/// Primary attributes for font matching: [`FontWidth`], [`FontStyle`] and [`FontWeight`]. +/// +/// These are used to [configure] a [`Query`]. +/// +/// [configure]: crate::Query::set_attributes +/// [`Query`]: crate::Query +#[derive(Copy, Clone, PartialEq, Default, Debug)] +pub struct Attributes { + pub width: FontWidth, + pub style: FontStyle, + pub weight: FontWeight, +} + +impl Attributes { + /// Creates new attributes from the given width, style and weight. + pub fn new(width: FontWidth, style: FontStyle, weight: FontWeight) -> Self { + Self { + width, + style, + weight, + } + } +} + +impl fmt::Display for Attributes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "width: {}, style: {}, weight: {}", + self.width, self.style, self.weight + ) + } +} diff --git a/crates/fontique/src/backend/android.rs b/crates/fontique/src/backend/android.rs new file mode 100644 index 00000000..a9bf78d0 --- /dev/null +++ b/crates/fontique/src/backend/android.rs @@ -0,0 +1,221 @@ +// Copyright 2024 the Parley Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use alloc::{ + boxed::Box, + str::FromStr, + string::{String, ToString}, + sync::Arc, + vec, + vec::Vec, +}; +use std::path::Path; + +use hashbrown::HashMap; +use roxmltree::{Document, Node}; + +use super::{ + FallbackKey, FamilyId, FamilyInfo, FamilyNameMap, GenericFamily, GenericFamilyMap, Language, + Script, scan, +}; + +// TODO: Use actual generic families here, where available, when fonts.xml is properly parsed. +// system-ui should map to `variant="compact"` in some scripts during fallback resolution. +const DEFAULT_GENERIC_FAMILIES: &[(GenericFamily, &[&str])] = &[ + ( + GenericFamily::SansSerif, + &["Roboto Flex", "Roboto", "Noto Sans"], + ), + (GenericFamily::Serif, &["Noto Serif"]), + (GenericFamily::Monospace, &["monospace"]), + (GenericFamily::Cursive, &["Dancing Script"]), + (GenericFamily::Fantasy, &["Noto Serif"]), + ( + GenericFamily::SystemUi, + &["Roboto Flex", "Roboto", "Noto Sans"], + ), + (GenericFamily::Emoji, &["Noto Color Emoji"]), + (GenericFamily::Math, &["Noto Sans Math", "Noto Sans"]), +]; + +pub(crate) struct SystemFonts { + pub(crate) name_map: Arc, + pub(crate) generic_families: Arc, + family_map: HashMap, + locale_fallback: Box<[(Language, FamilyId)]>, + script_fallback: Box<[(Script, FamilyId)]>, +} + +impl SystemFonts { + pub(crate) fn new() -> Self { + let android_root: String = std::env::var("ANDROID_ROOT").unwrap_or("/system".to_string()); + + let scan::ScannedCollection { + family_names: mut name_map, + families: family_map, + postscript_names, + .. + } = scan::ScannedCollection::from_paths(Path::new(&android_root).join("fonts").to_str(), 8); + let mut generic_families = GenericFamilyMap::default(); + for (family, names) in DEFAULT_GENERIC_FAMILIES { + generic_families.set( + *family, + names + .iter() + .filter_map(|name| name_map.get(name)) + .map(|name| name.id()), + ); + } + + let mut locale_fallback = vec![]; + let mut script_fallback = vec![]; + + // Try to get generic info from fonts.xml + if let Ok(s) = std::fs::read_to_string(Path::new(&android_root).join("etc/fonts.xml")) { + if let Ok(doc) = Document::parse(s.clone().as_str()) { + let root = doc.root_element(); + if root.tag_name().name() == "familyset" + || root + .attribute("version") + .is_some_and(|v| usize::from_str(v).is_ok_and(|x| x >= 21)) + { + for child in root.children() { + match child.tag_name().name() { + "alias" => { + if let Some((name, to)) = + child.attribute("name").zip(child.attribute("to")) + { + if child.attribute("weight").is_some() { + // weight aliases are an Android quirk and are not in­ + // teresting for use cases other than Android legacy. + continue; + } + let to_n = name_map.get_or_insert(to); + name_map.add_alias(to_n.id(), name); + } + } + "family" => { + if let Some(name) = child.attribute("name") { + let f = name_map.get_or_insert(name); + let _id = f.id(); + for _child in child.children() { + // TODO: map using postScriptName when available other­ + // wise use the file name, and perhaps if necess­ + // ary (e.g. if it's a collection), do something + // smarter, or something dumb that meets expecta­ + // tions on Android. + } + } else if let Some(langs) = child + .attribute("lang") + .map(|s| s.split(',').collect::>()) + { + let (_has_for, hasnt_for): ( + Vec>, + Vec>, + ) = child + .children() + .partition(|c| c.attribute("fallbackFor").is_some()); + { + // general fallback families + let (ps_named, _ps_unnamed): ( + Vec>, + Vec>, + ) = hasnt_for + .iter() + .partition(|c| c.attribute("postScriptName").is_some()); + + if let Some(family) = ps_named.iter().find_map(|x| { + postscript_names + .get(x.attribute("postScriptName").unwrap()) + }) { + for lang in langs { + if let Some(scr) = lang.strip_prefix("und-") { + // Undefined lang for script-only fallbacks + script_fallback.push(( + scr.parse().unwrap_or(Script::UNKNOWN), + *family, + )); + } else if let Ok(locale) = Language::parse(lang) { + if let Some(scr) = locale + .script() + .and_then(|s| s.parse:: + + diff --git a/platform/ohos/shell/page-templates/Index.mail.ets b/platform/ohos/shell/page-templates/Index.mail.ets index 9399b49c..dc6ac094 100644 --- a/platform/ohos/shell/page-templates/Index.mail.ets +++ b/platform/ohos/shell/page-templates/Index.mail.ets @@ -4,8 +4,12 @@ import { fileIo, picker } from '@kit.CoreFileKit'; import { inputMethod } from '@kit.IMEKit'; import { notificationManager } from '@kit.NotificationKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; +import { preferences } from '@kit.ArkData'; +import { webview } from '@kit.ArkWeb'; const LOG_TAG = 'FlectarOhos'; +const PRIVACY_STORE = 'flectar_ohos'; +const PRIVACY_KEY = 'privacy_accepted'; // Services the native module invokes from Rust; the closures run on the ArkTS // UI thread through a thread-safe function. `pickOpen`/`pickSave` are @@ -18,6 +22,7 @@ interface FlectarBridge { hideIme: () => void; notify: (title: string, body: string) => void; requestPasteboard: () => void; + openPrivacyPolicy: () => void; } // The exports of the native XComponent module (`onLoad` hands them over). @@ -35,9 +40,14 @@ struct Index { private nativeModule: FlectarNativeModule | undefined = undefined; private imeAttached: boolean = false; private pasteboardPermissionRequested: boolean = false; + @State private showPrivacyPolicy: boolean = false; + @State private privacyChecked: boolean = false; + @State private privacyAccepted: boolean = false; + private privacyController: webview.WebviewController = new webview.WebviewController(); aboutToAppear(): void { this.probeResources(); + this.loadPrivacyConsent(); } // The IME controller is process-scoped; its listeners capture this page, so @@ -55,9 +65,102 @@ struct Index { this.imeAttached = false; } - build(): void { + @Builder + privacyConsentView(): void { + Column({ space: 14 }) { + Text('隐私政策 / Privacy Policy') + .fontSize(20) + .fontWeight(700) + .fontColor('#1C1C1E') + Scroll() { + Text('欢迎使用 Flectar Mail。\n\n' + + 'Flectar Mail 在本机处理您的邮件、附件与账户数据,不会上传至 Flectar 服务器。' + + '剪贴板仅在您主动粘贴时读取纯文本。详情请阅读《隐私政策》。\n\n' + + 'Flectar Mail processes your mail, attachments and account data locally on this ' + + 'device and does not upload them to Flectar servers. The clipboard is read only ' + + 'when you paste. See the Privacy Policy for details.') + .fontSize(14) + .lineHeight(22) + .fontColor('#3C3C43') + } + .width('100%') + .layoutWeight(1) + Button('查看隐私政策 / View privacy policy') + .width('100%') + .height(44) + .backgroundColor('#F1F1F4') + .fontColor('#0A59F7') + .onClick((): void => { + this.showPrivacyPolicy = true; + }) + Button('同意并继续 / Agree and continue') + .width('100%') + .height(44) + .onClick((): void => { + this.acceptPrivacy(); + }) + Button('不同意 / Decline') + .width('100%') + .height(44) + .backgroundColor('#F1F1F4') + .fontColor('#66666E') + .onClick((): void => { + this.declinePrivacy(); + }) + } + .padding(24) + .width('100%') + .height('100%') + .backgroundColor('#FFFFFF') + .bindContentCover(this.showPrivacyPolicy, this.privacyPolicyView(), { + modalTransition: ModalTransition.DEFAULT, + }) + } + + @Builder + privacyPolicyView(): void { Column() { - XComponent({ + Row({ space: 8 }) { + Button('Back') + .height(36) + .onClick((): void => { + this.showPrivacyPolicy = false; + }) + Text('Privacy policy') + .fontSize(18) + .fontWeight(600) + .fontColor('#1C1C1E') + .layoutWeight(1) + } + .width('100%') + .backgroundColor('#FFFFFF') + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + + Web({ src: $rawfile('privacy.html'), controller: this.privacyController }) + .width('100%') + .layoutWeight(1) + } + .width('100%') + .height('100%') + .backgroundColor('#FFFFFF') + } + + build(): void { + if (!this.privacyChecked) { + Column() { + LoadingProgress() + .width(48) + .height(48) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .backgroundColor('#FFFFFF') + } else if (!this.privacyAccepted) { + this.privacyConsentView() + } else { + Column() { + XComponent({ id: 'flectar_mail_surface', type: XComponentType.SURFACE, libraryname: 'flectar_mail_ohos', @@ -71,9 +174,13 @@ struct Index { this.installBridge(context); } }) + } + .width('100%') + .height('100%') + .bindContentCover(this.showPrivacyPolicy, this.privacyPolicyView(), { + modalTransition: ModalTransition.DEFAULT, + }) } - .width('100%') - .height('100%') } // Diagnostic: resolves the app's own resources once per launch, so a hap @@ -128,6 +235,9 @@ struct Index { requestPasteboard: (): void => { this.requestPasteboardPermission(); }, + openPrivacyPolicy: (): void => { + this.openPrivacyPolicy(); + }, }); } @@ -344,6 +454,48 @@ struct Index { } } + // The consent screen gates the XComponent: no native module (and therefore + // no account or mail processing) starts before the user agrees. + private async loadPrivacyConsent(): Promise { + try { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const store = await preferences.getPreferences(context, PRIVACY_STORE); + this.privacyAccepted = (await store.get(PRIVACY_KEY, false)) as boolean; + } catch (error) { + hilog.error(0x0000, LOG_TAG, 'privacy preferences unavailable: %{public}s', + (error as BusinessError).message); + this.privacyAccepted = false; + } finally { + this.privacyChecked = true; + } + } + + private async acceptPrivacy(): Promise { + try { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const store = await preferences.getPreferences(context, PRIVACY_STORE); + await store.put(PRIVACY_KEY, true); + await store.flush(); + } catch (error) { + hilog.error(0x0000, LOG_TAG, 'privacy consent could not be stored: %{public}s', + (error as BusinessError).message); + } + this.privacyAccepted = true; + } + + private declinePrivacy(): void { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + context.terminateSelf().catch((error: BusinessError): void => { + hilog.error(0x0000, LOG_TAG, 'could not stop after declining privacy: %{public}s', + error.message); + }); + } + + // Shows the bundled privacy policy (About and onboarding rows). + private openPrivacyPolicy(): void { + this.showPrivacyPolicy = true; + } + private showPasteboardNotice(): void { try { this.getUIContext().getPromptAction().showToast({ diff --git a/ui/app.slint b/ui/app.slint index 94533c8f..44ce6afa 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -2177,6 +2177,14 @@ export component AppWindow inherits Window { } cancel-oauth => { root.cancel_oauth(); } import-accounts => { root.import_accounts(); } + open-link(url) => { root.open_email_link(url); } + import-account-backup => { + root.import_account_backup(); + if (root.connected_accounts.length > 0) { + root.settings_tab = "Accounts"; + root.settings_open = true; + } + } } // Keep the progressively painted shell inert until its callbacks have a diff --git a/ui/dialogs/settings-dialog.slint b/ui/dialogs/settings-dialog.slint index 730c9ff9..21cafc7a 100644 --- a/ui/dialogs/settings-dialog.slint +++ b/ui/dialogs/settings-dialog.slint @@ -2289,6 +2289,16 @@ export component SettingsDialog inherits Rectangle { height: self.preferred-height; padding-bottom: 0px; spacing: 0px; + SettingsLinkRow { + text: @tr("Privacy policy"); + description: @tr("How Flectar Mail handles your data"); + icon: @image-url("../icons/lucide/outline/lock.svg"); + accent: root.accent; + body-text: root.body-text; + muted-text: root.faint-text; + hover-background: root.accent-soft; + clicked => { root.open-link("https://flectar.com/legal/privacy/"); } + } SettingsLinkRow { text: @tr("Flectar"); description: @tr("Company website"); diff --git a/ui/views/onboarding-view.slint b/ui/views/onboarding-view.slint index 0ec0f283..ac2a2a01 100644 --- a/ui/views/onboarding-view.slint +++ b/ui/views/onboarding-view.slint @@ -59,6 +59,8 @@ export component OnboardingView inherits Rectangle { callback start-oauth(string); callback cancel-oauth(); callback import-accounts(); + callback import-account-backup(); + callback open-link(string); private property account-form-valid: root.account-email != "" && root.account-password != "" @@ -218,6 +220,14 @@ export component OnboardingView inherits Rectangle { enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving; clicked => { root.import-accounts(); } } + Button { + height: 40px; + text: @tr("Privacy policy"); + icon: @image-url("../icons/lucide/outline/lock.svg"); + has-icon: true; + enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving; + clicked => { root.open-link("https://flectar.com/legal/privacy/"); } + } if root.oauth-in-progress && root.oauth-authorization-url != "" : OAuthBrowserHelp { launch-failed: root.oauth-browser-error; retry => { root.retry-oauth-browser(); } From 9b81216c7968a552474c30c98ffc1ef113389970 Mon Sep 17 00:00:00 2001 From: springmin Date: Tue, 20 Oct 2026 09:15:21 +0800 Subject: [PATCH 13/45] ohos: localise the privacy policy and let consent be withdrawn The policy page now carries Simplified Chinese, English, Spanish, Turkish and Russian sections and a selector that mirrors Settings -> General; the application passes the configured language (system, en, es, tr, zh_Hans, ru) when it opens the page. Settings -> About gains a "Withdraw privacy consent" row that clears the stored choice and stops the app, so consent can be changed at any time. Regenerate the catalogs without line wrapping: gettext versions wrap the Language-Team header differently, which made the translation check fail on CI. --- platform/ohos/README.md | 16 +- platform/ohos/mail/src/bridge.rs | 46 +++- .../src/main/resources/rawfile/privacy.html | 228 ++++++++++++++++-- .../ohos/shell/page-templates/Index.mail.ets | 62 ++++- scripts/check-translations.sh | 4 +- ui/app.slint | 1 + ui/dialogs/settings-dialog.slint | 12 +- ui/views/onboarding-view.slint | 3 +- 8 files changed, 334 insertions(+), 38 deletions(-) diff --git a/platform/ohos/README.md b/platform/ohos/README.md index 8454cb00..2b5b95e5 100644 --- a/platform/ohos/README.md +++ b/platform/ohos/README.md @@ -161,13 +161,15 @@ handled (and asserted) in the script, do not "simplify" them away: in-app notifications setting is on. A refusal is final at the system level; the user can re-enable notifications in the system settings. - **Privacy**: the first launch shows a consent screen before the native module - (and therefore any account or mail processing) starts. The policy ships in - `shell/entry/src/main/resources/rawfile/privacy.html` (Simplified Chinese, - an English application supplement, and the official Flectar text) and is - reachable from the onboarding screen and Settings -> About. The host opens - this bundled page when the shared UI asks for - `https://flectar.com/legal/privacy/`; drop that interception once the - website covers the application-specific disclosures. + (and therefore any account or mail processing) starts; Settings -> About can + withdraw the consent, which clears the stored choice and stops the app. The + policy ships in `shell/entry/src/main/resources/rawfile/privacy.html` with + Simplified Chinese, English, Spanish, Turkish and Russian sections selected + by the configured language (the selector mirrors Settings -> General); + English also carries the official Flectar text. It is reachable from the + onboarding screen and Settings -> About. The host opens this bundled page + when the shared UI asks for `https://flectar.com/legal/privacy/`; drop that + interception once the website covers the application-specific disclosures. - **Clipboard**: copying needs no permission, but pasting reads the system pasteboard and therefore requires `ohos.permission.READ_PASTEBOARD`, an ACL (restricted) permission. The module declares it and the page requests it on diff --git a/platform/ohos/mail/src/bridge.rs b/platform/ohos/mail/src/bridge.rs index 478065d8..bb7cff04 100644 --- a/platform/ohos/mail/src/bridge.rs +++ b/platform/ohos/mail/src/bridge.rs @@ -30,6 +30,7 @@ static HIDE_IME: AtomicUsize = AtomicUsize::new(0); static NOTIFY: AtomicUsize = AtomicUsize::new(0); static REQUEST_PASTEBOARD: AtomicUsize = AtomicUsize::new(0); static OPEN_PRIVACY: AtomicUsize = AtomicUsize::new(0); +static WITHDRAW_PRIVACY: AtomicUsize = AtomicUsize::new(0); /// Request ids for the callback-based picker flows. static NEXT_REQUEST: AtomicI64 = AtomicI64::new(1); @@ -94,6 +95,13 @@ pub(super) unsafe extern "C" fn set_bridge( argv[0], b"openPrivacyPolicy\0", &OPEN_PRIVACY, + call_privacy, + ); + install_property( + env, + argv[0], + b"withdrawPrivacyConsent\0", + &WITHDRAW_PRIVACY, call_zero_arg, ); undefined(env) @@ -157,10 +165,20 @@ pub(super) unsafe extern "C" fn submit_bridge_result( pub(super) fn open_url(url: &str) -> Result<(), String> { // The privacy policy ships as a local page until the website edition // covers the application-specific disclosures; keep the URL in the shared - // UI so other hosts open the published policy. - const PRIVACY_URL: &str = "https://flectar.com/legal/privacy/"; - if url == PRIVACY_URL { - return call_closure(&OPEN_PRIVACY, "openPrivacyPolicy", ()); + // UI so other hosts open the published policy. The shared UI appends the + // configured language as `?lang=`. + const PRIVACY_PREFIX: &str = "https://flectar.com/legal/privacy/"; + if url.starts_with(PRIVACY_PREFIX) { + if url.contains("consent=withdraw") { + return call_closure(&WITHDRAW_PRIVACY, "withdrawPrivacyConsent", ()); + } + let language = url + .split_once("?lang=") + .map(|(_, rest)| rest.split('&').next().unwrap_or("")) + .filter(|value| !value.is_empty()) + .unwrap_or("system") + .to_owned(); + return call_closure(&OPEN_PRIVACY, "openPrivacyPolicy", language); } let url = CString::new(url).map_err(|_| "authorization address is malformed".to_owned())?; call_closure(&OPEN_URL, "openUrl", url) @@ -493,6 +511,26 @@ unsafe extern "C" fn call_zero_arg( unsafe { call_with_args(env, js_callback, &[], "notice") }; } +/// JS-thread half of [`open_privacy_policy`] (one language argument). +unsafe extern "C" fn call_privacy( + env: napi_env, + js_callback: napi_value, + _context: *mut c_void, + data: *mut c_void, +) { + // SAFETY: `data` is the `String` boxed by `call_closure`; this callback + // owns it. + let language = unsafe { Box::from_raw(data.cast::()) }; + // SAFETY: NAPI calls on the JS thread with live handles; the string bytes + // outlive the call. + unsafe { + let Some(arg) = string_value(env, language.as_bytes()) else { + return; + }; + call_with_args(env, js_callback, &[arg], "openPrivacyPolicy"); + } +} + /// JS-thread half of [`notify`]. unsafe extern "C" fn call_notify( env: napi_env, diff --git a/platform/ohos/shell/entry/src/main/resources/rawfile/privacy.html b/platform/ohos/shell/entry/src/main/resources/rawfile/privacy.html index fdf7625a..79c64ff8 100644 --- a/platform/ohos/shell/entry/src/main/resources/rawfile/privacy.html +++ b/platform/ohos/shell/entry/src/main/resources/rawfile/privacy.html @@ -14,6 +14,7 @@ body { color: #e5e5e7; background: #111113; } a { color: #7aa7ff; } code { background: #26262a; } + .lang-switch select { background: #1c1c1f; color: #e5e5e7; border-color: #3a3a40; } } h1 { font-size: 1.7rem; margin: 0 0 4px; } h2 { font-size: 1.25rem; margin: 2rem 0 0.6rem; } @@ -26,14 +27,25 @@ padding: 1px 4px; border-radius: 4px; font-size: 0.85em; } a { color: #0a59f7; } hr { border: 0; border-top: 1px solid #e2e2e6; margin: 2.5rem 0; } - .lang-switch { text-align: right; font-size: 0.85rem; margin-bottom: 4px; } - .lang-switch a { color: #6b6b70; text-decoration: none; } + .lang-switch { text-align: right; font-size: 0.85rem; margin-bottom: 4px; color: #6b6b70; } + .lang-switch select { margin-left: 6px; padding: 2px 6px; border-radius: 6px; + border: 1px solid #d0d0d6; background: #ffffff; color: #1c1c1e; font: inherit; } - +
+ + +
-
+

隐私政策

@@ -102,8 +114,7 @@

十二、政策更新

十三、联系我们

Flectar,Attn: Data Protection Officer,privacy@flectar.com,Málaga, Spain。

-
- +
+ + + diff --git a/platform/ohos/shell/page-templates/Index.mail.ets b/platform/ohos/shell/page-templates/Index.mail.ets index dc6ac094..d7fc196b 100644 --- a/platform/ohos/shell/page-templates/Index.mail.ets +++ b/platform/ohos/shell/page-templates/Index.mail.ets @@ -22,7 +22,8 @@ interface FlectarBridge { hideIme: () => void; notify: (title: string, body: string) => void; requestPasteboard: () => void; - openPrivacyPolicy: () => void; + openPrivacyPolicy: (lang: string) => void; + withdrawPrivacyConsent: () => void; } // The exports of the native XComponent module (`onLoad` hands them over). @@ -44,6 +45,7 @@ struct Index { @State private privacyChecked: boolean = false; @State private privacyAccepted: boolean = false; private privacyController: webview.WebviewController = new webview.WebviewController(); + private privacyLanguage: string = 'system'; aboutToAppear(): void { this.probeResources(); @@ -139,6 +141,9 @@ struct Index { Web({ src: $rawfile('privacy.html'), controller: this.privacyController }) .width('100%') .layoutWeight(1) + .onPageEnd((): void => { + this.applyPrivacyLanguage(); + }) } .width('100%') .height('100%') @@ -235,8 +240,11 @@ struct Index { requestPasteboard: (): void => { this.requestPasteboardPermission(); }, - openPrivacyPolicy: (): void => { - this.openPrivacyPolicy(); + openPrivacyPolicy: (lang: string): void => { + this.openPrivacyPolicy(lang); + }, + withdrawPrivacyConsent: (): void => { + this.withdrawPrivacyConsent(); }, }); } @@ -491,9 +499,53 @@ struct Index { }); } - // Shows the bundled privacy policy (About and onboarding rows). - private openPrivacyPolicy(): void { + // Shows the bundled privacy policy (About and onboarding rows). The page + // starts from the configured language and falls back to the system one. + private openPrivacyPolicy(language: string): void { + this.privacyLanguage = language; this.showPrivacyPolicy = true; + // The page applies the language once it is loaded (onPageEnd); calling + // runJavaScript before the Web component is bound crashes ArkWeb. + } + + // Withdrawing clears the stored consent and stops the application, so no + // further account or mail processing happens; the next start asks again. + private withdrawPrivacyConsent(): void { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + this.clearPrivacyConsent(context); + } + + private async clearPrivacyConsent(context: common.UIAbilityContext): Promise { + try { + const store = await preferences.getPreferences(context, PRIVACY_STORE); + await store.put(PRIVACY_KEY, false); + await store.flush(); + } catch (error) { + hilog.error(0x0000, LOG_TAG, 'privacy consent could not be cleared: %{public}s', + (error as BusinessError).message); + } + try { + this.getUIContext().getPromptAction().showToast({ + message: 'Privacy consent withdrawn. The app will close.', + duration: 2000, + }); + } catch (error) { + hilog.error(0x0000, LOG_TAG, 'withdraw toast failed'); + } + setTimeout((): void => { + context.terminateSelf().catch((error: BusinessError): void => { + hilog.error(0x0000, LOG_TAG, 'could not stop after withdrawing consent: %{public}s', + error.message); + }); + }, 1500); + } + + private applyPrivacyLanguage(): void { + const language = JSON.stringify(this.privacyLanguage); + this.privacyController.runJavaScript(`window.setPolicyLanguage(${language})`) + .catch((error: BusinessError): void => { + hilog.error(0x0000, LOG_TAG, 'privacy language failed: %{public}s', error.message); + }); } private showPasteboardNotice(): void { diff --git a/scripts/check-translations.sh b/scripts/check-translations.sh index d396cc7d..d426504f 100644 --- a/scripts/check-translations.sh +++ b/scripts/check-translations.sh @@ -29,7 +29,9 @@ diff -u \ <(sed '/^"POT-Creation-Date:/d' "$work_dir/flectar-mail.pot") for language in de es fr ru tr zh_Hans; do - msgmerge --quiet --no-fuzzy-matching \ + # --no-wrap keeps the header (and long messages) byte-identical across + # gettext versions, so the check does not depend on the runner's gettext. + msgmerge --quiet --no-fuzzy-matching --no-wrap \ --output-file "$work_dir/flectar-mail-$language.po" \ "lang/$language/LC_MESSAGES/flectar-mail.po" "$work_dir/flectar-mail.pot" diff -u \ diff --git a/ui/app.slint b/ui/app.slint index 44ce6afa..ef912edf 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -2178,6 +2178,7 @@ export component AppWindow inherits Window { cancel-oauth => { root.cancel_oauth(); } import-accounts => { root.import_accounts(); } open-link(url) => { root.open_email_link(url); } + language-mode: root.language_mode; import-account-backup => { root.import_account_backup(); if (root.connected_accounts.length > 0) { diff --git a/ui/dialogs/settings-dialog.slint b/ui/dialogs/settings-dialog.slint index 21cafc7a..065e387c 100644 --- a/ui/dialogs/settings-dialog.slint +++ b/ui/dialogs/settings-dialog.slint @@ -2297,7 +2297,17 @@ export component SettingsDialog inherits Rectangle { body-text: root.body-text; muted-text: root.faint-text; hover-background: root.accent-soft; - clicked => { root.open-link("https://flectar.com/legal/privacy/"); } + clicked => { root.open-link("https://flectar.com/legal/privacy/?lang=" + root.language_mode); } + } + SettingsLinkRow { + text: @tr("Withdraw privacy consent"); + description: @tr("Closes the app and asks for consent again on the next start"); + icon: @image-url("../icons/lucide/outline/undo.svg"); + accent: root.accent; + body-text: root.body-text; + muted-text: root.faint-text; + hover-background: root.accent-soft; + clicked => { root.open-link("https://flectar.com/legal/privacy/?consent=withdraw&lang=" + root.language_mode); } } SettingsLinkRow { text: @tr("Flectar"); diff --git a/ui/views/onboarding-view.slint b/ui/views/onboarding-view.slint index ac2a2a01..8fe238cb 100644 --- a/ui/views/onboarding-view.slint +++ b/ui/views/onboarding-view.slint @@ -23,6 +23,7 @@ export component OnboardingView inherits Rectangle { callback save-oauth-apps(string, string, string); in property oauth-settings-saving: false; in-out property oauth-settings-error: ""; + in property language-mode: "system"; in property startup-ready: false; in property account-setup-in-progress: false; in property oauth-in-progress: false; @@ -226,7 +227,7 @@ export component OnboardingView inherits Rectangle { icon: @image-url("../icons/lucide/outline/lock.svg"); has-icon: true; enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving; - clicked => { root.open-link("https://flectar.com/legal/privacy/"); } + clicked => { root.open-link("https://flectar.com/legal/privacy/?lang=" + root.language-mode); } } if root.oauth-in-progress && root.oauth-authorization-url != "" : OAuthBrowserHelp { launch-failed: root.oauth-browser-error; From d823705bf601d253a174a5781fd4ed92c7dc0764 Mon Sep 17 00:00:00 2001 From: springmin Date: Tue, 20 Oct 2026 09:43:19 +0800 Subject: [PATCH 14/45] ohos: add jurisdiction-specific privacy disclosures Spanish (GDPR): legal bases, the full data-subject rights including restriction, objection and portability, the AEPD complaint route and the one-month response deadline; the Spanish version applies to users in Spain and the EU. Turkish (KVKK): legal bases, the Article 11 rights, the 30-day deadline and the KVKK complaint route. Russian (152-FZ): legal bases, the Article 14 rights, the 10-working-day deadline and Roskomnadzor. English: UK ICO and California CCPA/CPRA statements. --- .../src/main/resources/rawfile/privacy.html | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/platform/ohos/shell/entry/src/main/resources/rawfile/privacy.html b/platform/ohos/shell/entry/src/main/resources/rawfile/privacy.html index 79c64ff8..8f86d1e7 100644 --- a/platform/ohos/shell/entry/src/main/resources/rawfile/privacy.html +++ b/platform/ohos/shell/entry/src/main/resources/rawfile/privacy.html @@ -158,7 +158,12 @@

Flectar Mail (application supplement)

delete your data in the app (Settings, Storage), withdraw permission in the system settings, and contact privacy@flectar.com for any request - or complaint; we respond within 15 working days. + or complaint; we respond within 15 working days. UK users may complain to + the Information Commissioner's Office (ico.org.uk). +
  • California residents. We do not sell or share personal + information as defined by the CCPA/CPRA. You may request access to or + deletion of your information at + privacy@flectar.com.

  • @@ -167,7 +172,7 @@

    Flectar Privacy Policy (official text)

    + +

    Privacy Policy

    +

    Last updated: 20 October 2026 · Effective: 20 October 2026

    +

    This policy applies to the Flectar Mail application (the “app”). This English version applies to users outside the markets covered by the Simplified Chinese, Spanish, Turkish and Russian versions; where a market-specific version exists, that version prevails for users in that market. Flectar Mail is a local-first mail client: mail content is processed on your device and is not uploaded to Flectar servers.

    +

    1. Data controller

    +

    Flectar (Spain). Contact: privacy@flectar.com; phone (+34) 614 05 79 79; address: Málaga, Spain.

    +

    Flectar is the controller for the limited processing described in this policy (for example, support correspondence you send us). The app processes mail, attachments, contacts and calendars locally on your device; Flectar does not receive or store that content. Your mail provider (and Google or Microsoft when you sign in with them) is an independent controller of the data it processes under its own privacy policy.

    +

    2. Data we process

    +
      +
    • Account and sign-in: email address, username, password or OAuth refresh token. Credentials are stored only in the system secure store (on HarmonyOS, the Asset Store); they are never written to plain files, included in exports, or sent to Flectar.
    • +
    • Mail and sync: messages, attachments, contacts and calendars are processed and stored locally. When you sign in with Google or Microsoft, the provider performs the authentication under its own policy.
    • +
    • Notifications: new-mail notifications are generated locally; no push service is used.
    • +
    • Clipboard: only when you paste, the app reads plain text from the clipboard to insert it into the active field; the text is not stored, logged or transmitted, is not read in the background, and clipboard changes are not monitored.
    • +
    • Import and export: only at your request, through the system file picker; backups contain local mail and settings but no passwords or OAuth tokens.
    • +
    +

    We do not collect data unrelated to these features: no system contacts, precise location, device identifiers (IMEI/OAID), app list or biometric data; no advertising, personalised recommendations or automated decisions.

    +

    Legal bases (UK GDPR). We process data on the basis of the contract (Article 6(1)(b) UK GDPR) to provide the mail service, legitimate interests (Article 6(1)(f)) for security and abuse prevention, and consent (Article 6(1)(a)) for notifications and clipboard access, which you may withdraw at any time.

    +

    3. System permissions

    +
      +
    • ohos.permission.INTERNET: sync, sending and sign-in.
    • +
    • ohos.permission.READ_PASTEBOARD: clipboard reading only when you paste; without it only pasting is unavailable.
    • +
    • Notifications (optional): new-mail alerts.
    • +
    +

    We do not request camera, microphone, location, contacts, calendar, phone or storage permissions; files are chosen through the system picker.

    +

    4. Third-party SDKs

    +

    The app contains no third-party SDKs (analytics, advertising or crash reporting) and does not share data through SDKs.

    +

    5. Sharing

    +

    We do not upload mail content to Flectar servers, sell it or provide it to advertisers. To work, the app connects directly to the mail provider you configure (IMAP/SMTP) and, when you sign in with Google or Microsoft, to their OAuth services. Those providers process data under their own privacy policies.

    +

    6. Retention and location

    +

    Mail, attachments, drafts, contacts and calendars remain on your device until you delete them, clear local data or uninstall the app. Flectar does not store mail content on its servers. Support correspondence is retained for up to 3 years after the request is resolved, unless a longer period is required by law.

    +

    7. International transfers

    +

    Flectar does not receive mail content; the app connects directly to the provider you choose. If the provider is outside your country, the transfer occurs between you and that provider under its policy. If you contact us, support correspondence is processed in Spain (EU); where required, we rely on adequacy regulations or Standard Contractual Clauses.

    +

    8. Your rights

    +
      +
    • Access and copy: view mail in the app; export backups or snapshots from Settings → Storage.
    • +
    • Rectification and erasure: edit or delete messages, contacts and calendars; “Delete all local data” in Settings → Storage; uninstall the app.
    • +
    • Restriction, objection and portability: request restriction of processing, object to it, or receive your data in a structured format.
    • +
    • Withdraw consent: disable permissions in the system settings and notifications in the app.
    • +
    • Account closure: the app does not require a Flectar account; removing mail accounts and local data is equivalent to closing an account.
    • +
    +

    To exercise these rights, contact privacy@flectar.com. We respond within one month (extendable by two months for complex requests) and within 15 working days for other enquiries. UK users may complain to the Information Commissioner’s Office (ico.org.uk). California residents: we do not sell or share personal information as defined by the CCPA/CPRA; you may request access, deletion, correction, and limitation of the use of sensitive personal information, and we will not discriminate against you for exercising these rights. Requests are answered within 45 days; you may also contact the California Privacy Protection Agency or the Attorney General.

    +

    9. Children

    +

    The app is not directed to children below the digital age of consent in their country (16 in much of the EEA, 14 in Spain and China, 13 in the UK and US, 18 in Türkiye and Russia). Younger users may use it only with the consent of a parent or guardian. If we learn that we hold children’s data without consent, we will delete it.

    +

    10. Security

    +

    Credentials are stored in the system secure store, app data in the application sandbox, and network connections use TLS. We take reasonable measures but cannot guarantee absolute security.

    +

    11. Complaints

    +

    For privacy questions or complaints, write to privacy@flectar.com; we respond within 15 working days. You may also complain to the Information Commissioner’s Office (UK), the California Privacy Protection Agency or the Attorney General (California), or your local data-protection authority.

    +

    12. Changes

    +

    We update the date on this page when the policy changes; material changes are notified in the app and, where required, consent is requested again.

    +

    13. Contact

    +

    Flectar, Attn: Data Protection Officer, privacy@flectar.com, Málaga, Spain.

    +

    The Flectar website and business communications are covered by the separate Flectar Privacy Policy published at https://flectar.com/legal/privacy/.

    + - - diff --git a/platform/ohos/shell/page-templates/Index.mail.ets b/platform/ohos/shell/page-templates/Index.mail.ets index 5d8c1c7e..a44a9691 100644 --- a/platform/ohos/shell/page-templates/Index.mail.ets +++ b/platform/ohos/shell/page-templates/Index.mail.ets @@ -2,129 +2,9 @@ import { abilityAccessCtrl, common, PermissionRequestResult, Permissions, Want } import { BusinessError } from '@kit.BasicServicesKit'; import { fileIo, picker } from '@kit.CoreFileKit'; import { inputMethod } from '@kit.IMEKit'; -import { notificationManager } from '@kit.NotificationKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; -import { preferences } from '@kit.ArkData'; -import { webview } from '@kit.ArkWeb'; -import { i18n } from '@kit.LocalizationKit'; const LOG_TAG = 'FlectarOhos'; -const PRIVACY_STORE = 'flectar_ohos'; -const PRIVACY_KEY = 'privacy_accepted'; - -// Consent-screen copy. The page follows the same rules as the bundled policy -// (privacy.html): the application language when known, otherwise the system -// language, with English as the fallback. The selector offers the same five -// languages as the policy page. -interface ConsentCopy { - title: string; - welcome: string; - summary: string; - bulletLocal: string; - bulletClipboard: string; - bulletWithdraw: string; - viewPolicy: string; - agree: string; - decline: string; - language: string; - systemLanguage: string; -} - -const CONSENT_EN: ConsentCopy = { - title: 'Privacy Policy', - welcome: 'Welcome to Flectar Mail', - summary: 'Flectar Mail processes your mail, attachments and account data locally on ' + - 'this device and does not upload them to Flectar servers. See the Privacy Policy ' + - 'for details.', - bulletLocal: 'Mail and attachments are processed on this device only', - bulletClipboard: 'The clipboard is read only when you paste', - bulletWithdraw: 'Consent can be withdrawn in Settings → About', - viewPolicy: 'View privacy policy', - agree: 'Agree and continue', - decline: 'Decline', - language: 'Language', - systemLanguage: 'System language', -}; - -const CONSENT_ZH_HANS: ConsentCopy = { - title: '隐私政策', - welcome: '欢迎使用 Flectar Mail', - summary: '本应用在本机处理您的邮件、附件与账户数据,不会上传至 Flectar 服务器。' + - '详情请阅读《隐私政策》。', - bulletLocal: '邮件与附件只在本机处理,不上传 Flectar 服务器', - bulletClipboard: '剪贴板仅在你主动粘贴时读取纯文本', - bulletWithdraw: '同意可随时在「设置 → 关于」中撤回', - viewPolicy: '查看隐私政策', - agree: '同意并继续', - decline: '不同意', - language: '语言', - systemLanguage: '跟随系统', -}; - -const CONSENT_ES: ConsentCopy = { - title: 'Política de privacidad', - welcome: 'Te damos la bienvenida a Flectar Mail', - summary: 'Flectar Mail procesa tu correo, archivos adjuntos y datos de cuenta ' + - 'localmente en este dispositivo y no los sube a los servidores de Flectar. ' + - 'Consulta la Política de privacidad para obtener más detalles.', - bulletLocal: 'El correo y los archivos adjuntos se procesan solo en este dispositivo', - bulletClipboard: 'El portapapeles solo se lee cuando pegas', - bulletWithdraw: 'Puedes retirar el consentimiento en Ajustes → Acerca de', - viewPolicy: 'Ver la política de privacidad', - agree: 'Aceptar y continuar', - decline: 'Rechazar', - language: 'Idioma', - systemLanguage: 'Idioma del sistema', -}; - -const CONSENT_TR: ConsentCopy = { - title: 'Gizlilik Politikası', - welcome: 'Flectar Mail’e hoş geldiniz', - summary: 'Flectar Mail postalarınızı, eklerinizi ve hesap verilerinizi bu cihazda ' + - 'yerel olarak işler ve Flectar sunucularına yüklemez. Ayrıntılar için Gizlilik ' + - 'Politikasına bakın.', - bulletLocal: 'Posta ve ekler yalnızca bu cihazda işlenir', - bulletClipboard: 'Pano yalnızca yapıştırdığınızda okunur', - bulletWithdraw: 'Onayı Ayarlar → Hakkında bölümünden geri çekebilirsiniz', - viewPolicy: 'Gizlilik politikasını görüntüle', - agree: 'Kabul et ve devam et', - decline: 'Reddet', - language: 'Dil', - systemLanguage: 'Sistem dili', -}; - -const CONSENT_RU: ConsentCopy = { - title: 'Политика конфиденциальности', - welcome: 'Добро пожаловать в Flectar Mail', - summary: 'Flectar Mail обрабатывает вашу почту, вложения и данные учётной записи ' + - 'локально на этом устройстве и не загружает их на серверы Flectar. Подробности — ' + - 'в Политике конфиденциальности.', - bulletLocal: 'Почта и вложения обрабатываются только на этом устройстве', - bulletClipboard: 'Буфер обмена читается только при вставке', - bulletWithdraw: 'Согласие можно отозвать в разделе «Настройки → О программе»', - viewPolicy: 'Просмотреть политику конфиденциальности', - agree: 'Принять и продолжить', - decline: 'Отклонить', - language: 'Язык', - systemLanguage: 'Системный язык', -}; - -function consentCopyFor(language: string): ConsentCopy { - if (language === 'zh_Hans') { - return CONSENT_ZH_HANS; - } - if (language === 'es') { - return CONSENT_ES; - } - if (language === 'tr') { - return CONSENT_TR; - } - if (language === 'ru') { - return CONSENT_RU; - } - return CONSENT_EN; -} - // Services the native module invokes from Rust; the closures run on the ArkTS // UI thread through a thread-safe function. `pickOpen`/`pickSave` are // request/response: the page reports the outcome through `submitBridgeResult`. @@ -134,10 +14,7 @@ interface FlectarBridge { pickSave: (requestId: number, sourcePath: string, suggestedName: string) => void; showIme: () => void; hideIme: () => void; - notify: (title: string, body: string) => void; requestPasteboard: () => void; - openPrivacyPolicy: (lang: string) => void; - withdrawPrivacyConsent: () => void; } // The exports of the native XComponent module (`onLoad` hands them over). @@ -155,20 +32,8 @@ struct Index { private nativeModule: FlectarNativeModule | undefined = undefined; private imeAttached: boolean = false; private pasteboardPermissionRequested: boolean = false; - @State private showPrivacyPolicy: boolean = false; - @State private privacyChecked: boolean = false; - @State private privacyAccepted: boolean = false; - private privacyController: webview.WebviewController = new webview.WebviewController(); - private privacyLanguage: string = 'system'; - @State private consentMode: string = 'system'; - @State private consentSelected: number = 0; - @State private consentOptions: SelectOption[] = []; - @State private consentText: ConsentCopy = CONSENT_EN; - aboutToAppear(): void { this.probeResources(); - this.initConsentLanguage(); - this.loadPrivacyConsent(); } // The IME controller is process-scoped; its listeners capture this page, so @@ -186,246 +51,9 @@ struct Index { this.imeAttached = false; } - @Builder - privacyConsentView(): void { - Column() { - Column() { - Row({ space: 8 }) { - Blank() - Text(this.consentText.language) - .fontSize(13) - .fontColor('#8E8E93') - Select(this.consentOptions) - .selected(this.consentSelected) - .value(this.consentOptions.length > this.consentSelected - ? this.consentOptions[this.consentSelected].value : '') - .font({ size: 13 }) - .fontColor('#1C1C1E') - .backgroundColor('#F2F2F7') - .borderRadius(6) - .onSelect((index: number, value: string): void => { - this.selectConsentLanguage(index); - }) - } - .width('100%') - .padding({ top: 8 }) - - Scroll() { - Column({ space: 14 }) { - Image($r('app.media.app_icon')) - .width(64) - .height(64) - .borderRadius(14) - Text(this.consentText.title) - .fontSize(24) - .fontWeight(700) - .fontColor('#1C1C1E') - Text(this.consentText.welcome) - .fontSize(16) - .fontWeight(600) - .fontColor('#1C1C1E') - Text(this.consentText.summary) - .fontSize(15) - .lineHeight(24) - .fontColor('#3C3C43') - - Column({ space: 14 }) { - Row({ space: 10 }) { - Image($r('app.media.ic_check_blue')) - .width(16) - .height(16) - .objectFit(ImageFit.Contain) - Text(this.consentText.bulletLocal) - .fontSize(14) - .lineHeight(20) - .fontColor('#1C1C1E') - .layoutWeight(1) - } - .width('100%') - .alignItems(VerticalAlign.Top) - - Row({ space: 10 }) { - Image($r('app.media.ic_check_blue')) - .width(16) - .height(16) - .objectFit(ImageFit.Contain) - Text(this.consentText.bulletClipboard) - .fontSize(14) - .lineHeight(20) - .fontColor('#1C1C1E') - .layoutWeight(1) - } - .width('100%') - .alignItems(VerticalAlign.Top) - - Row({ space: 10 }) { - Image($r('app.media.ic_check_blue')) - .width(16) - .height(16) - .objectFit(ImageFit.Contain) - Text(this.consentText.bulletWithdraw) - .fontSize(14) - .lineHeight(20) - .fontColor('#1C1C1E') - .layoutWeight(1) - } - .width('100%') - .alignItems(VerticalAlign.Top) - } - .width('100%') - .padding(16) - .borderRadius(10) - .backgroundColor('#F7F7F9') - } - .width('100%') - .alignItems(HorizontalAlign.Start) - .padding({ top: 24, bottom: 24 }) - } - .width('100%') - .layoutWeight(1) - .scrollBar(BarState.Off) - - Column({ space: 10 }) { - Row({ space: 12 }) { - Image($r('app.media.ic_privacy')) - .width(20) - .height(20) - .objectFit(ImageFit.Contain) - Text(this.consentText.viewPolicy) - .fontSize(16) - .fontWeight(600) - .fontColor('#1C1C1E') - .layoutWeight(1) - Text('›') - .fontSize(22) - .fontColor('#8E8E93') - } - .width('100%') - .height(48) - .padding({ left: 16, right: 16 }) - .borderRadius(8) - .backgroundColor('#FFFFFF') - .border({ width: 1, color: '#E2E2E6' }) - .onClick((): void => { - this.openPrivacyPolicy(this.consentMode); - }) - - Row({ space: 12 }) { - Image($r('app.media.ic_agree')) - .width(20) - .height(20) - .objectFit(ImageFit.Contain) - Text(this.consentText.agree) - .fontSize(16) - .fontWeight(600) - .fontColor('#FFFFFF') - .layoutWeight(1) - Text('›') - .fontSize(22) - .fontColor('#FFFFFF') - } - .width('100%') - .height(48) - .padding({ left: 16, right: 16 }) - .borderRadius(8) - .backgroundColor('#0A59F7') - .onClick((): void => { - this.acceptPrivacy(); - }) - - Row({ space: 12 }) { - Image($r('app.media.ic_decline')) - .width(20) - .height(20) - .objectFit(ImageFit.Contain) - Text(this.consentText.decline) - .fontSize(16) - .fontWeight(500) - .fontColor('#8E8E93') - .layoutWeight(1) - } - .width('100%') - .height(48) - .padding({ left: 16, right: 16 }) - .borderRadius(8) - .backgroundColor('#F5F5F7') - .onClick((): void => { - this.declinePrivacy(); - }) - } - .width('100%') - .padding({ bottom: 16 }) - } - .width('100%') - .constraintSize({ maxWidth: 520 }) - .height('100%') - } - .width('100%') - .height('100%') - .alignItems(HorizontalAlign.Center) - .padding({ left: 20, right: 20 }) - .backgroundColor('#FFFFFF') - .bindContentCover(this.showPrivacyPolicy, this.privacyPolicyView(), { - modalTransition: ModalTransition.DEFAULT, - }) - } - - @Builder - privacyPolicyView(): void { - Column() { - Row({ space: 4 }) { - Row() { - Image($r('app.media.ic_back')) - .width(20) - .height(20) - .objectFit(ImageFit.Contain) - } - .width(36) - .height(36) - .borderRadius(18) - .justifyContent(FlexAlign.Center) - .accessibilityText('Back') - .onClick((): void => { - this.showPrivacyPolicy = false; - }) - Text('Privacy policy') - .fontSize(18) - .fontWeight(600) - .fontColor('#1C1C1E') - .layoutWeight(1) - } - .width('100%') - .backgroundColor('#FFFFFF') - .padding({ left: 12, right: 12, top: 8, bottom: 8 }) - - Web({ src: $rawfile('privacy.html'), controller: this.privacyController }) - .width('100%') - .layoutWeight(1) - .onPageEnd((): void => { - this.applyPrivacyLanguage(); - }) - } - .width('100%') - .height('100%') - .backgroundColor('#FFFFFF') - } - build(): void { - if (!this.privacyChecked) { - Column() { - LoadingProgress() - .width(48) - .height(48) - } - .width('100%') - .height('100%') - .justifyContent(FlexAlign.Center) - .backgroundColor('#FFFFFF') - } else if (!this.privacyAccepted) { - this.privacyConsentView() - } else { - Column() { - XComponent({ + Column() { + XComponent({ id: 'flectar_mail_surface', type: XComponentType.SURFACE, libraryname: 'flectar_mail_ohos', @@ -439,13 +67,9 @@ struct Index { this.installBridge(context); } }) - } - .width('100%') - .height('100%') - .bindContentCover(this.showPrivacyPolicy, this.privacyPolicyView(), { - modalTransition: ModalTransition.DEFAULT, - }) } + .width('100%') + .height('100%') } // Diagnostic: resolves the app's own resources once per launch, so a hap @@ -494,18 +118,9 @@ struct Index { hideIme: (): void => { this.hideIme(); }, - notify: (title: string, body: string): void => { - this.notify(title, body); - }, requestPasteboard: (): void => { this.requestPasteboardPermission(); }, - openPrivacyPolicy: (lang: string): void => { - this.openPrivacyPolicy(lang); - }, - withdrawPrivacyConsent: (): void => { - this.withdrawPrivacyConsent(); - }, }); } @@ -665,36 +280,6 @@ struct Index { } } - // New-mail notification, published by the native module through the - // publisher installed on the platform context. The permission itself is - // requested by EntryAbility once the page has loaded. - private notify(title: string, body: string): void { - try { - const request: notificationManager.NotificationRequest = { - id: Date.now() % 2147483647, - content: { - notificationContentType: - notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, - normal: { title, text: body }, - }, - }; - notificationManager.publish(request) - .then((): void => { - hilog.info(0x0000, LOG_TAG, 'notification published'); - }) - .catch((error: BusinessError): void => { - hilog.error(0x0000, LOG_TAG, - 'notification publish failed: %{public}d %{public}s', error.code, error.message); - }); - } catch (error) { - hilog.error(0x0000, LOG_TAG, - 'notification failed: %{public}s', (error as BusinessError).message); - } - } - - // The clipboard is readable only with the ACL permission - // ohos.permission.READ_PASTEBOARD. Native code asks for it after the first - // denied paste; granting takes effect for the next paste. private requestPasteboardPermission(): void { if (this.pasteboardPermissionRequested) { return; @@ -722,151 +307,6 @@ struct Index { } } - // The consent screen gates the XComponent: no native module (and therefore - // no account or mail processing) starts before the user agrees. - private async loadPrivacyConsent(): Promise { - try { - const context = this.getUIContext().getHostContext() as common.UIAbilityContext; - const store = await preferences.getPreferences(context, PRIVACY_STORE); - this.privacyAccepted = (await store.get(PRIVACY_KEY, false)) as boolean; - } catch (error) { - hilog.error(0x0000, LOG_TAG, 'privacy preferences unavailable: %{public}s', - (error as BusinessError).message); - this.privacyAccepted = false; - } finally { - this.privacyChecked = true; - } - } - - private async acceptPrivacy(): Promise { - try { - const context = this.getUIContext().getHostContext() as common.UIAbilityContext; - const store = await preferences.getPreferences(context, PRIVACY_STORE); - await store.put(PRIVACY_KEY, true); - await store.flush(); - } catch (error) { - hilog.error(0x0000, LOG_TAG, 'privacy consent could not be stored: %{public}s', - (error as BusinessError).message); - } - this.privacyAccepted = true; - } - - private declinePrivacy(): void { - const context = this.getUIContext().getHostContext() as common.UIAbilityContext; - context.terminateSelf().catch((error: BusinessError): void => { - hilog.error(0x0000, LOG_TAG, 'could not stop after declining privacy: %{public}s', - error.message); - }); - } - - // Mirrors privacy.html's language resolution: the configured mode when it - // is one of the five bundled languages, otherwise the system language with - // English as the fallback. The selector lists the same choices in the same - // order, so choosing a language here also drives the policy the row opens. - private resolveSystemLanguage(): string { - let language = 'en'; - try { - language = i18n.System.getSystemLanguage().toLowerCase(); - } catch (error) { - hilog.error(0x0000, LOG_TAG, 'system language unavailable: %{public}s', - (error as BusinessError).message); - } - if (language.startsWith('zh')) { - return 'zh_Hans'; - } - if (language.startsWith('es')) { - return 'es'; - } - if (language.startsWith('tr')) { - return 'tr'; - } - if (language.startsWith('ru')) { - return 'ru'; - } - return 'en'; - } - - private initConsentLanguage(): void { - const resolved = this.resolveSystemLanguage(); - this.consentMode = 'system'; - this.consentSelected = 0; - this.consentText = consentCopyFor(resolved); - this.consentOptions = this.buildLanguageOptions(resolved); - } - - private selectConsentLanguage(index: number): void { - const modes: string[] = ['system', 'en', 'es', 'tr', 'zh_Hans', 'ru']; - if (index < 0 || index >= modes.length) { - return; - } - const mode = modes[index]; - const resolved = mode === 'system' ? this.resolveSystemLanguage() : mode; - this.consentMode = mode; - this.consentSelected = index; - this.consentText = consentCopyFor(resolved); - this.consentOptions = this.buildLanguageOptions(resolved); - } - - private buildLanguageOptions(resolved: string): SelectOption[] { - return [ - { value: consentCopyFor(resolved).systemLanguage }, - { value: 'English' }, - { value: 'Español' }, - { value: 'Türkçe' }, - { value: '简体中文' }, - { value: 'Русский' }, - ]; - } - - // Shows the bundled privacy policy (About and onboarding rows). The page - // starts from the configured language and falls back to the system one. - private openPrivacyPolicy(language: string): void { - this.privacyLanguage = language; - this.showPrivacyPolicy = true; - // The page applies the language once it is loaded (onPageEnd); calling - // runJavaScript before the Web component is bound crashes ArkWeb. - } - - // Withdrawing clears the stored consent and stops the application, so no - // further account or mail processing happens; the next start asks again. - private withdrawPrivacyConsent(): void { - const context = this.getUIContext().getHostContext() as common.UIAbilityContext; - this.clearPrivacyConsent(context); - } - - private async clearPrivacyConsent(context: common.UIAbilityContext): Promise { - try { - const store = await preferences.getPreferences(context, PRIVACY_STORE); - await store.put(PRIVACY_KEY, false); - await store.flush(); - } catch (error) { - hilog.error(0x0000, LOG_TAG, 'privacy consent could not be cleared: %{public}s', - (error as BusinessError).message); - } - try { - this.getUIContext().getPromptAction().showToast({ - message: 'Privacy consent withdrawn. The app will close.', - duration: 2000, - }); - } catch (error) { - hilog.error(0x0000, LOG_TAG, 'withdraw toast failed'); - } - setTimeout((): void => { - context.terminateSelf().catch((error: BusinessError): void => { - hilog.error(0x0000, LOG_TAG, 'could not stop after withdrawing consent: %{public}s', - error.message); - }); - }, 1500); - } - - private applyPrivacyLanguage(): void { - const language = JSON.stringify(this.privacyLanguage); - this.privacyController.runJavaScript(`window.setPolicyLanguage(${language})`) - .catch((error: BusinessError): void => { - hilog.error(0x0000, LOG_TAG, 'privacy language failed: %{public}s', error.message); - }); - } - private showPasteboardNotice(): void { try { this.getUIContext().getPromptAction().showToast({ diff --git a/src/main.rs b/src/main.rs index 3e378735..ac18fd18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,8 +31,6 @@ mod mail_render_projection; mod mail_scope; mod mail_setup; mod mail_view_model; -mod notifications; -pub use notifications::NotificationPublisher; mod mail_work; mod message_appearance; mod oauth_browser; @@ -1255,8 +1253,6 @@ impl InboxState { pub struct PlatformContext { /// Host URL dispatch for mail links and browser-based authorization. pub external_urls: ExternalUrlOpener, - /// Host publisher for new-mail system notifications. - pub notification_publisher: NotificationPublisher, pub paths: Paths, pub documents: Arc, pub credentials: flectar_mail_core::accounts::credentials::CredentialStoreHandle, @@ -1271,7 +1267,6 @@ impl PlatformContext { Ok(Self { paths, external_urls: ExternalUrlOpener::default(), - notification_publisher: NotificationPublisher::default(), documents: documents::default_provider(), credentials, oauth_redirects: Arc::new( @@ -1292,7 +1287,6 @@ impl PlatformContext { Self { paths: Paths::new(data_root.join("data"), cache_dir), external_urls: ExternalUrlOpener::default(), - notification_publisher: NotificationPublisher::default(), documents, credentials, oauth_redirects, @@ -4081,7 +4075,6 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> let startup_runtime = Rc::clone(&runtime); let startup_pending_core_updates = Arc::clone(&pending_core_updates); let startup_core_wake = core_updates_wake.clone(); - let startup_notification_publisher = platform.notification_publisher.clone(); let startup_app = app.as_weak(); let startup_paths_for_ui = platform.paths.clone(); let startup_tray = tray.as_ref().map(|tray| tray.as_weak()); @@ -4254,7 +4247,6 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> core.clone(), Arc::clone(&startup_pending_core_updates), startup_core_wake.clone(), - startup_notification_publisher.clone(), ); if !startup_benchmark_disable_background { core.notify_ui_ready(); diff --git a/src/notifications.rs b/src/notifications.rs deleted file mode 100644 index 66c8a937..00000000 --- a/src/notifications.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! New-mail notifications: the host publisher and the event helper. -//! -//! The core emits `CoreEvent::MailNew` for chime-worthy arrivals. Hosts that -//! can raise system notifications install a publisher on the -//! `PlatformContext` (the OpenHarmony shell publishes through the system -//! notification service); without one — or with notifications turned off — -//! everything here is a no-op. - -use std::sync::Arc; - -use super::CoreMailSource; - -/// Callback a host installs to publish system notifications. -type PublishHandler = Arc; - -/// Host publisher for system notifications. The default does nothing, so a -/// host without a notification service can leave it untouched. -#[derive(Clone, Default)] -pub struct NotificationPublisher { - handler: Option, -} - -impl NotificationPublisher { - /// Publish a notification; the host marshals the call onto whatever - /// thread its native API requires and returns promptly. - pub fn new(publish: impl Fn(&str, &str) + Send + Sync + 'static) -> Self { - Self { - handler: Some(Arc::new(publish)), - } - } - - fn publish(&self, title: &str, body: &str) { - if let Some(handler) = &self.handler { - handler(title, body); - } - } -} - -/// Publish a system notification for a chime-worthy arrival. -pub(crate) async fn notify_new_mail( - core: &CoreMailSource, - thread_ids: &[i64], - publisher: &NotificationPublisher, -) { - if publisher.handler.is_none() { - return; - } - let Ok(settings) = core.load_settings().await else { - return; - }; - if !settings.notifications_enabled { - return; - } - let Some(thread_id) = thread_ids.first().copied() else { - return; - }; - let Ok(outline) = core.file_core().get_thread_outline(thread_id).await else { - return; - }; - let summary = outline.thread; - // The sender is the most useful conversation opener; fall back to the - // account when every participant is one of our own addresses. - let sender = summary - .participants - .iter() - .find(|address| !address.email.eq_ignore_ascii_case(&summary.account_email)) - .or_else(|| summary.participants.first()) - .map(|address| { - address - .name - .clone() - .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| address.email.clone()) - }) - .unwrap_or_else(|| summary.account_email.clone()); - let subject = if summary.subject.trim().is_empty() { - "(no subject)".to_owned() - } else { - summary.subject.clone() - }; - let title = if thread_ids.len() > 1 { - format!( - "{} — {} new messages", - summary.account_email, - thread_ids.len() - ) - } else { - sender - }; - publisher.publish(&title, &subject); -} diff --git a/src/platform/ios.rs b/src/platform/ios.rs index 6eed0c54..2d7eddd4 100644 --- a/src/platform/ios.rs +++ b/src/platform/ios.rs @@ -29,7 +29,6 @@ pub fn launch_context() -> Result>, wake: UiWake, - publisher: crate::notifications::NotificationPublisher, ) { let mut events = core.subscribe_events(); runtime.spawn(async move { @@ -660,13 +659,6 @@ pub(crate) fn spawn_core_event_listener( .record_threads(thread_ids); true } - Ok(CoreEvent::MailNew { thread_ids, .. }) => { - // The same batch also produces `MailUpdated`, which drives - // the list refresh; this event only feeds the system - // notification. - crate::notifications::notify_new_mail(&core, &thread_ids, &publisher).await; - false - } Ok(CoreEvent::AccountState { account_id, sync_state, diff --git a/ui/app.slint b/ui/app.slint index 85988a1f..4662bebb 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -542,6 +542,7 @@ export global I18n { if (key == "Could not load account preferences: {}") { return @tr("Could not load account preferences: {}", argument); } if (key == "Your edited signature was preserved. Choose No signature to keep it as message text before inserting another.") { return @tr("Your edited signature was preserved. Choose No signature to keep it as message text before inserting another."); } if (key == "Certificate import failed: {}") { return @tr("Certificate import failed: {}", argument); } + if (key == "Paste the PEM certificate into the certificate field.") { return @tr("Paste the PEM certificate into the certificate field."); } if (key == "Testing IMAP and SMTP connections…") { return @tr("Testing IMAP and SMTP connections…"); } if (key == "Testing POP3 and SMTP connections…") { return @tr("Testing POP3 and SMTP connections…"); } return ""; @@ -2289,8 +2290,6 @@ export component AppWindow inherits Window { } cancel-oauth => { root.cancel_oauth(); } import-accounts => { root.import_accounts(); } - open-link(url) => { root.open_email_link(url); } - language-mode: root.language_mode; } // Keep the progressively painted shell inert until its callbacks have a diff --git a/ui/dialogs/settings-dialog.slint b/ui/dialogs/settings-dialog.slint index e60c16c5..bb13712b 100644 --- a/ui/dialogs/settings-dialog.slint +++ b/ui/dialogs/settings-dialog.slint @@ -2444,26 +2444,6 @@ export component SettingsDialog inherits Rectangle { height: self.preferred-height; padding-bottom: 0px; spacing: 0px; - SettingsLinkRow { - text: @tr("Privacy policy"); - description: @tr("How Flectar Mail handles your data"); - icon: @image-url("../icons/lucide/outline/lock.svg"); - accent: root.accent; - body-text: root.body-text; - muted-text: root.faint-text; - hover-background: root.accent-soft; - clicked => { root.open-link("https://flectar.com/legal/privacy/?lang=" + root.language_mode); } - } - SettingsLinkRow { - text: @tr("Withdraw privacy consent"); - description: @tr("Closes the app and asks for consent again on the next start"); - icon: @image-url("../icons/lucide/outline/undo.svg"); - accent: root.accent; - body-text: root.body-text; - muted-text: root.faint-text; - hover-background: root.accent-soft; - clicked => { root.open-link("https://flectar.com/legal/privacy/?consent=withdraw&lang=" + root.language_mode); } - } SettingsLinkRow { text: @tr("Flectar"); description: @tr("Company website"); diff --git a/ui/views/onboarding-view.slint b/ui/views/onboarding-view.slint index e9bd3087..cfd2c9f0 100644 --- a/ui/views/onboarding-view.slint +++ b/ui/views/onboarding-view.slint @@ -24,7 +24,6 @@ export component OnboardingView inherits Rectangle { callback save-oauth-apps(string, string, string); in property oauth-settings-saving: false; in-out property oauth-settings-error: ""; - in property language-mode: "system"; in property startup-ready: false; in property account-setup-in-progress: false; in property oauth-in-progress: false; @@ -61,7 +60,6 @@ export component OnboardingView inherits Rectangle { callback start-oauth(string); callback cancel-oauth(); callback import-accounts(); - callback open-link(string); private property account-form-valid: root.account-email != "" && root.account-password != "" @@ -228,14 +226,6 @@ export component OnboardingView inherits Rectangle { enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving; clicked => { root.import-accounts(); } } - Button { - height: 40px; - text: @tr("Privacy policy"); - icon: @image-url("../icons/lucide/outline/lock.svg"); - has-icon: true; - enabled: !root.account-setup-in-progress && !root.oauth-in-progress && !root.oauth-settings-saving; - clicked => { root.open-link("https://flectar.com/legal/privacy/?lang=" + root.language-mode); } - } if root.oauth-in-progress && root.oauth-authorization-url != "" : OAuthBrowserHelp { launch-failed: root.oauth-browser-error; retry => { root.retry-oauth-browser(); } From a4125207a3fac33a86e8ecc623ee3346680cc8a7 Mon Sep 17 00:00:00 2001 From: springmin Date: Sun, 25 Oct 2026 22:12:49 +0800 Subject: [PATCH 40/45] ohos: fix surface lifecycle, fences and wheel scaling - request a redraw on Surface/SurfaceShown instead of only invalidating the cache, and skip drawing while the window is hidden so the request survives until it is shown - wait for and close the acquire fence returned by RequestBuffer, and abort a rejected flush instead of leaking a dequeued buffer - key buffer geometry to the current surface size so a resize on the same native window reconfigures the queue - expose only the active renderer to Slint and re-associate the software renderer when the GPU attach or render falls back - scale mouse-wheel degrees by the user scroll step, leaving touchpad pixel deltas at 1:1 --- platform/ohos/backend/src/ffi.rs | 19 +++++++++ platform/ohos/backend/src/platform.rs | 25 ++++++++---- platform/ohos/backend/src/present.rs | 31 ++++++++++---- platform/ohos/backend/src/window.rs | 54 +++++++++++++++++-------- platform/ohos/backend/src/xcomponent.rs | 22 ++++++---- 5 files changed, 114 insertions(+), 37 deletions(-) diff --git a/platform/ohos/backend/src/ffi.rs b/platform/ohos/backend/src/ffi.rs index c1aa808e..6d18d742 100644 --- a/platform/ohos/backend/src/ffi.rs +++ b/platform/ohos/backend/src/ffi.rs @@ -411,6 +411,9 @@ unsafe extern "C" { pub fn OH_ArkUI_AxisEvent_GetAxisAction(event: *const ArkUI_UIInputEvent) -> i32; pub fn OH_ArkUI_AxisEvent_GetVerticalAxisValue(event: *const ArkUI_UIInputEvent) -> f64; pub fn OH_ArkUI_AxisEvent_GetHorizontalAxisValue(event: *const ArkUI_UIInputEvent) -> f64; + /// Scroll step in degrees for a mouse wheel (0 for a touchpad, whose + /// axis value is already in pixels). + pub fn OH_ArkUI_AxisEvent_GetScrollStep(event: *const ArkUI_UIInputEvent) -> i32; /// Position of a directional input event (touch, mouse or axis) relative /// to the component, in physical pixels. pub fn OH_ArkUI_PointerEvent_GetX(event: *const ArkUI_UIInputEvent) -> f32; @@ -558,6 +561,22 @@ unsafe extern "C" { /// `mmap` failure return value. pub const MAP_FAILED: *mut c_void = usize::MAX as *mut c_void; +/// `struct pollfd` from ``; used to wait for the buffer acquire fence. +#[repr(C)] +pub struct PollFd { + pub fd: c_int, + pub events: i16, + pub revents: i16, +} + +/// There is data (or the fence signalled) to read. +pub const POLLIN: i16 = 0x001; + +unsafe extern "C" { + pub fn poll(fds: *mut PollFd, nfds: u64, timeout: c_int) -> c_int; + pub fn close(fd: c_int) -> c_int; +} + #[cfg(test)] mod tests { use super::*; diff --git a/platform/ohos/backend/src/platform.rs b/platform/ohos/backend/src/platform.rs index 93ecc20a..a5d9b02d 100644 --- a/platform/ohos/backend/src/platform.rs +++ b/platform/ohos/backend/src/platform.rs @@ -140,6 +140,7 @@ impl OhosPlatform { // resume (software), or (re)attach the GPU surface. w.invalidate_frame(); w.attach_surface(window.0, size); + w.request_redraw(); } crate::log::info(&format!( "platform: surface {width}x{height} density {density}" @@ -181,6 +182,9 @@ impl OhosPlatform { BackendEvent::SurfaceShown => { if let Some(w) = self.window.borrow().as_ref() { w.invalidate_frame(); + // Invalidate only resets the renderer's partial-repaint + // cache; it does not schedule a frame. + w.request_redraw(); } crate::log::info("surface shown; repaint requested"); } @@ -193,22 +197,29 @@ impl OhosPlatform { if self.gate.is_destroying() { return; } + // Keep `needs_redraw` set while hidden: the retained frame is skipped + // by the presenter, and the next SurfaceShown must schedule a fresh + // frame instead of finding the request already consumed. + if !self.visible.load(Ordering::Relaxed) { + return; + } let Some(size) = surface_size else { return }; let Some(window) = self.window.borrow().clone() else { return; }; let started = Instant::now(); - let drawn = window.draw_if_needed(size, |renderer| { - match presenter.present(renderer, size.width, size.height) { - PresentOutcome::Presented | PresentOutcome::Skipped => {} - PresentOutcome::Failed(code) => { - crate::log::error(&format!("present failed: {code}")); - } - } + let (drawn, outcome) = window.draw_if_needed(size, |renderer| { + presenter.present(renderer, size.width, size.height) }); if drawn { self.perf.record(started.elapsed()); } + if let Some(PresentOutcome::Failed(code)) = outcome { + // The frame was dropped; request another so a transient buffer + // queue failure does not leave stale pixels on screen. + crate::log::error(&format!("present failed: {code}")); + window.request_redraw(); + } } } diff --git a/platform/ohos/backend/src/present.rs b/platform/ohos/backend/src/present.rs index 03908493..a1966968 100644 --- a/platform/ohos/backend/src/present.rs +++ b/platform/ohos/backend/src/present.rs @@ -112,7 +112,7 @@ pub enum PresentOutcome { /// Buffer-queue presenter for one `OHNativeWindow`. pub struct Presenter { window: *mut ffi::OHNativeWindow, - configured: bool, + configured_size: Option<(u32, u32)>, /// Persistent CPU frame: the software renderer repaints only dirty regions /// into it (`ReusedBuffer` semantics), so key repeats and cursor blinks /// never pay for a full-window rasterization. @@ -128,7 +128,7 @@ impl Presenter { pub fn new(visible: Arc) -> Self { Self { window: null_mut(), - configured: false, + configured_size: None, frame: Vec::new(), frame_size: (0, 0), visible, @@ -138,7 +138,7 @@ impl Presenter { /// Attach (or re-attach) the surface pointer. pub fn set_surface(&mut self, window: *mut c_void) { if self.window != window { - self.configured = false; + self.configured_size = None; } self.window = window.cast(); } @@ -146,7 +146,7 @@ impl Presenter { /// Detach the surface; draws become no-ops until the next `set_surface`. pub fn clear(&mut self) { self.window = null_mut(); - self.configured = false; + self.configured_size = None; // The next surface must be painted from scratch; drop the frame so the // renderer keeps nothing stale (the renderer is invalidated separately // through `OhosWindow::invalidate_frame`). @@ -231,7 +231,9 @@ impl Presenter { // the destroy path waits for our ack, which this thread only sends from // its event loop (never while present() is running). unsafe { - if !self.configured { + // The geometry must follow a window resize on the same native + // window; a boolean "configured" flag would keep the first size. + if self.configured_size != Some((width, height)) { ffi::OH_NativeWindow_NativeWindowHandleOpt( self.window, ffi::SET_BUFFER_GEOMETRY, @@ -243,7 +245,7 @@ impl Presenter { ffi::SET_FORMAT, ffi::NATIVEBUFFER_PIXEL_FMT_RGBA_8888, ); - self.configured = true; + self.configured_size = Some((width, height)); } let mut buffer: *mut ffi::OHNativeWindowBuffer = null_mut(); @@ -253,6 +255,18 @@ impl Presenter { &mut buffer, &mut fence_fd, ); + // The acquire fence belongs to this call. Wait for the producer to + // finish writing and close it; leaking it costs one fd per frame + // and writing before it signals shows stale buffer contents. + if fence_fd >= 0 { + let mut poll_fd = ffi::PollFd { + fd: fence_fd, + events: ffi::POLLIN, + revents: 0, + }; + let _ = ffi::poll(&mut poll_fd, 1, 1000); + let _ = ffi::close(fence_fd); + } if rc != 0 || buffer.is_null() { return PresentOutcome::Failed(rc); } @@ -312,6 +326,9 @@ impl Presenter { if rc == 0 { PresentOutcome::Presented } else { + // A rejected flush leaves the buffer dequeued; return it so the + // queue is not permanently one slot short. + let _ = ffi::OH_NativeWindow_NativeWindowAbortBuffer(self.window, buffer); PresentOutcome::Failed(rc) } } @@ -343,7 +360,7 @@ mod tests { #[test] fn clear_resets_the_frame() { - let mut presenter = Presenter::new(); + let mut presenter = Presenter::new(Arc::new(AtomicBool::new(true))); presenter.frame = vec![PremultipliedRgbaColor::default(); 4]; presenter.frame_size = (2, 2); presenter.clear(); diff --git a/platform/ohos/backend/src/window.rs b/platform/ohos/backend/src/window.rs index 587240bb..4b763911 100644 --- a/platform/ohos/backend/src/window.rs +++ b/platform/ohos/backend/src/window.rs @@ -20,6 +20,7 @@ use i_slint_core::renderer::RendererSealed; use crate::event::{BackendEvent, TouchPhase}; use crate::gpu::GpuRenderer; +use crate::present::PresentOutcome; /// Slint window backed by the XComponent surface. pub struct OhosWindow { @@ -132,6 +133,11 @@ impl OhosWindow { Err(error) => { self.gpu_active.set(false); self.gpu_surface.set(None); + // The component may have been associated with the GPU + // renderer before this attempt; re-associate the software + // renderer so text metrics and rendering keep working after + // the fallback. + self.associate_software(); crate::log::error(&format!( "gpu: surface attach failed; using the software renderer: {error}" )); @@ -159,26 +165,45 @@ impl OhosWindow { .set_repaint_buffer_type(RepaintBufferType::ReusedBuffer); } + /// Re-associate the software renderer with this window adapter. Slint + /// performs the association once at component creation; a GPU attempt can + /// replace it, so a later fallback has to restore it explicitly. + fn associate_software(&self) { + if let Some(adapter) = self.self_weak.upgrade() { + let adapter: Rc = adapter; + self.renderer.set_window_adapter(&adapter); + } + } + /// Render if a redraw was requested since the last call. Software frames /// are handed to `present_software` (the buffer-queue presenter); GPU /// frames present themselves through the wgpu surface. + /// + /// Returns whether a frame was attempted and, for the software path, the + /// presenter's outcome so the caller can retry a failed flush. pub fn draw_if_needed( &self, size: PhysicalSize, - present_software: impl FnOnce(&SoftwareRenderer), - ) -> bool { + present_software: impl FnOnce(&SoftwareRenderer) -> PresentOutcome, + ) -> (bool, Option) { if !self.needs_redraw.replace(false) { - return false; + return (false, None); } match (&self.gpu, self.gpu_active.get()) { (Some(gpu), true) => { if let Err(error) = crate::gpu::render(gpu, size) { - crate::log::error(&format!("gpu: render failed: {error}")); + crate::log::error(&format!( + "gpu: render failed; falling back to software: {error}" + )); + self.gpu_active.set(false); + self.associate_software(); + let outcome = present_software(&self.renderer); + return (true, Some(outcome)); } + (true, None) } - _ => present_software(&self.renderer), + _ => (true, Some(present_software(&self.renderer))), } - true } /// Dispatch the windowing-system scale factor (called once per surface @@ -263,16 +288,13 @@ impl WindowAdapter for OhosWindow { } fn renderer(&self) -> &dyn Renderer { - // The GPU renderer is handed to Slint as soon as it exists: the window - // adapter association (`WindowInner::set_component`) and rendering - // notifiers are registered on whatever renderer is active *then*, - // which predates the surface. The femtovg renderer answers text - // metrics without a canvas and no-ops its render until one is set, and - // this backend's draw path still decides between the GPU renderer and - // the software presenter (see `draw_if_needed`). - match &self.gpu { - Some(gpu) => gpu, - None => &self.renderer, + // Only the renderer that is actually active may be visible to Slint. + // Returning the suspended GPU renderer before its surface exists makes + // `WindowInner::set_component` associate it, and a later software + // fallback then has no adapter association. + match (&self.gpu, self.gpu_active.get()) { + (Some(gpu), true) => gpu, + _ => &self.renderer, } } diff --git a/platform/ohos/backend/src/xcomponent.rs b/platform/ohos/backend/src/xcomponent.rs index 30ef7761..a86472bc 100644 --- a/platform/ohos/backend/src/xcomponent.rs +++ b/platform/ohos/backend/src/xcomponent.rs @@ -33,10 +33,11 @@ const TOUCH_LOG_LIMIT: u32 = 40; static SCROLL_LOGGED: AtomicU32 = AtomicU32::new(0); const SCROLL_LOG_LIMIT: u32 = 10; -/// A mouse wheel notch reports 15 degrees per scroll step. Slint scrolls 50 -/// logical pixels per wheel line (the same step the desktop backend uses), so -/// a notch moves about three text lines. -const LOGICAL_PX_PER_WHEEL_DEGREE: f32 = 50.0 / 15.0; +/// Slint scrolls 50 logical pixels per wheel line, matching the desktop +/// backend. OHOS mouse-wheel axis values are in degrees and include the user's +/// scroll-step setting; touchpad axis values are already in pixels, and +/// `GetScrollStep` returns 0 for them. +const LOGICAL_PX_PER_WHEEL_STEP: f32 = 50.0; /// Modifier state used for character translation: OHOS key events carry only /// key codes, not text. @@ -286,12 +287,19 @@ unsafe extern "C" fn on_axis_event( if vertical == 0.0 && horizontal == 0.0 { return; } + let scroll_step = unsafe { ffi::OH_ArkUI_AxisEvent_GetScrollStep(event) }; + let logical_per_axis_unit = if scroll_step > 0 { + LOGICAL_PX_PER_WHEEL_STEP / scroll_step as f32 + } else { + // Touchpad: the axis value is already a pixel delta. + 1.0 + }; let x = unsafe { ffi::OH_ArkUI_PointerEvent_GetX(event) }; let y = unsafe { ffi::OH_ArkUI_PointerEvent_GetY(event) }; let seen = SCROLL_LOGGED.fetch_add(1, Ordering::Relaxed); if seen < SCROLL_LOG_LIMIT { crate::log::info(&format!( - "scroll #{seen}: action={action} raw=({horizontal:.2}, {vertical:.2}) at ({x:.0}, {y:.0})" + "scroll #{seen}: action={action} step={scroll_step} raw=({horizontal:.2}, {vertical:.2}) at ({x:.0}, {y:.0})" )); } // OHOS reports positive axis values toward the end of the content (wheel @@ -300,8 +308,8 @@ unsafe extern "C" fn on_axis_event( send(BackendEvent::Scroll { x, y, - delta_x: -horizontal * LOGICAL_PX_PER_WHEEL_DEGREE, - delta_y: -vertical * LOGICAL_PX_PER_WHEEL_DEGREE, + delta_x: -horizontal * logical_per_axis_unit, + delta_y: -vertical * logical_per_axis_unit, }); } From c6e3900ac94a76ea65fef4c1a56050f7ad9c11bc Mon Sep 17 00:00:00 2001 From: springmin Date: Sun, 25 Oct 2026 22:22:01 +0800 Subject: [PATCH 41/45] ohos: keep the shared certificate picker path unchanged Do not change mail_setup's mobile certificate-import flow in the port PR: Android and iOS keep their existing behavior, and the OHOS picker work stays in the platform layer. Importing a certificate through DocumentProvider can be proposed separately if the shared hosts want it. --- src/mail_setup.rs | 80 +++++++++++++++++++++++------------------------ src/main.rs | 2 +- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/mail_setup.rs b/src/mail_setup.rs index 69ee5b33..e54c3861 100644 --- a/src/mail_setup.rs +++ b/src/mail_setup.rs @@ -2,14 +2,10 @@ //! material; private keys must never enter account settings or backups. use super::*; -pub(super) fn register( - app: &AppWindow, - runtime: &Rc, - documents: &std::sync::Arc, -) { +pub(super) fn register(app: &AppWindow, _runtime: &Rc) { let weak = app.as_weak(); - let runtime = Rc::clone(runtime); - let provider = std::sync::Arc::clone(documents); + #[cfg(desktop)] + let runtime = Rc::clone(_runtime); app.on_import_mail_certificate(move || { let Some(app) = weak.upgrade() else { return; @@ -17,42 +13,46 @@ pub(super) fn register( if app.get_account_setup_in_progress() { return; } - let import = provider.import_with(documents::DocumentDialog { - title: "Import server certificate".into(), - filter: Some(("PEM certificate".into(), &["pem", "crt", "cer"])), - }); - let request: documents::DocumentFuture> = Box::pin(async move { - // Read before the document is dropped: a host's staged copy lives - // only as long as the ImportedDocument. - let Some(document) = import.await? else { - return Ok(None); - }; - read_certificate(document.path()).await.map(Some) - }); - let weak = app.as_weak(); - documents::run_request(&runtime, request, move |result| { - let Some(app) = weak.upgrade() else { - return; - }; - if app.get_account_setup_in_progress() { - return; - } - match result { - Ok(Some(pem)) => { - app.set_trusted_certificate_pem(pem.into()); - app.set_sync_status(UiMessage::plain( - "Certificate imported. Connect to verify the server.", - )); - } - Ok(None) => {} - Err(error) => { - app.set_sync_status(UiMessage::detail("Certificate import failed: {}", error)) - } - } - }); + #[cfg(desktop)] + { + let weak = app.as_weak(); + runtime.spawn(async move { + let Some(file) = rfd::AsyncFileDialog::new() + .set_title("Import server certificate") + .add_filter("PEM certificate", &["pem", "crt", "cer"]) + .pick_file() + .await + else { + return; + }; + let result = read_certificate(file.path()).await; + let _ = weak.upgrade_in_event_loop(move |app| { + if app.get_account_setup_in_progress() { + return; + } + match result { + Ok(pem) => { + app.set_trusted_certificate_pem(pem.into()); + app.set_sync_status(UiMessage::plain( + "Certificate imported. Connect to verify the server.", + )); + } + Err(error) => app.set_sync_status(UiMessage::detail( + "Certificate import failed: {}", + error, + )), + } + }); + }); + } + #[cfg(mobile)] + app.set_sync_status(UiMessage::plain( + "Paste the PEM certificate into the certificate field.", + )); }); } +#[cfg(desktop)] async fn read_certificate(path: &std::path::Path) -> Result { use tokio::io::AsyncReadExt; let file = tokio::fs::File::open(path) diff --git a/src/main.rs b/src/main.rs index ac18fd18..1f1d85c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6833,7 +6833,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> platform.oauth_redirects.clone(), platform.external_urls.clone(), ); - mail_setup::register(&app, &runtime, &platform.documents); + mail_setup::register(&app, &runtime); let suggestion_app = app.as_weak(); let suggestion_state = Rc::clone(&state); From 8022c58c16dab4d840414af52ed15a838ac4e8df Mon Sep 17 00:00:00 2001 From: springmin Date: Sun, 25 Oct 2026 22:53:37 +0800 Subject: [PATCH 42/45] ohos: forward IME password type, action key and cursor updates - pass the focused field's InputType::Password through the show hook, use VISIBLE_PASSWORD/DONE, and detach/attach when the keyboard type changes - subscribe sendFunctionKey and send a full Return press/release for the keyboard action key; the reported enter-key value varies by keyboard and is not a fixed code - forward InputMethodRequest::Update's caret rectangle to the OHOS controller's updateCursor - remove the remaining privacy bridge closures and URL interception left after the privacy UI was dropped --- platform/ohos/backend/src/ffi.rs | 2 + platform/ohos/backend/src/ime.rs | 37 +++-- platform/ohos/backend/src/window.rs | 17 ++- platform/ohos/mail/src/bridge.rs | 136 ++++++++++-------- platform/ohos/mail/src/lib.rs | 2 +- .../ohos/shell/page-templates/Index.mail.ets | 69 +++++++-- 6 files changed, 183 insertions(+), 80 deletions(-) diff --git a/platform/ohos/backend/src/ffi.rs b/platform/ohos/backend/src/ffi.rs index 6d18d742..3a094095 100644 --- a/platform/ohos/backend/src/ffi.rs +++ b/platform/ohos/backend/src/ffi.rs @@ -110,6 +110,8 @@ unsafe extern "C" { ) -> napi_status; pub fn napi_get_undefined(env: napi_env, result: *mut napi_value) -> napi_status; pub fn napi_create_int64(env: napi_env, value: i64, result: *mut napi_value) -> napi_status; + pub fn napi_get_boolean(env: napi_env, value: bool, result: *mut napi_value) -> napi_status; + pub fn napi_create_double(env: napi_env, value: f64, result: *mut napi_value) -> napi_status; pub fn napi_get_value_int64(env: napi_env, value: napi_value, result: *mut i64) -> napi_status; /// With `buf == NULL` and `bufsize == 0` the required length (excluding the /// NUL terminator) is written to `result`. diff --git a/platform/ohos/backend/src/ime.rs b/platform/ohos/backend/src/ime.rs index fa4c98b5..b20906d8 100644 --- a/platform/ohos/backend/src/ime.rs +++ b/platform/ohos/backend/src/ime.rs @@ -15,31 +15,44 @@ use crate::xcomponent; /// ArkTS hooks installed by the shell (`show` attaches the controller and /// raises the keyboard, `hide` lowers it). struct Hooks { - show: fn(), + show: fn(bool), hide: fn(), + update_cursor: fn(f32, f32, f32, f32), } static HOOKS: OnceLock = OnceLock::new(); /// Install the platform hooks. First registration wins; the shell calls this /// once during startup, before any text field can gain focus. -pub fn set_hooks(show: fn(), hide: fn()) { - let _ = HOOKS.set(Hooks { show, hide }); +pub fn set_hooks(show: fn(bool), hide: fn(), update_cursor: fn(f32, f32, f32, f32)) { + let _ = HOOKS.set(Hooks { + show, + hide, + update_cursor, + }); } /// Called by the window adapter when Slint enables or disables the input /// method (i.e. a text field gained or lost focus). -pub(crate) fn request(enable: bool) { +pub(crate) fn request(enable: bool, password: bool) { let Some(hooks) = HOOKS.get() else { return; }; if enable { - (hooks.show)(); + (hooks.show)(password); } else { (hooks.hide)(); } } +/// Forward the editable field's cursor rectangle to the system keyboard so +/// composing text and the candidate window follow the caret. +pub(crate) fn update_cursor(left: f32, top: f32, width: f32, height: f32) { + if let Some(hooks) = HOOKS.get() { + (hooks.update_cursor)(left, top, width, height); + } +} + /// Committed text from the IME. Multi-character commits (autocomplete) arrive /// as one event; Slint inserts the whole string. pub fn commit_text(text: &str) { @@ -68,9 +81,13 @@ pub fn delete_left(count: usize) { /// The keyboard's action key (e.g. Enter/Newline). pub fn function_key_enter() { - xcomponent::send(BackendEvent::Key { - text: slint::platform::Key::Return.into(), - down: true, - repeat: false, - }); + // Send the complete press/release pair, like a physical Enter key. + let text: slint::SharedString = slint::platform::Key::Return.into(); + for down in [true, false] { + xcomponent::send(BackendEvent::Key { + text: text.clone(), + down, + repeat: false, + }); + } } diff --git a/platform/ohos/backend/src/window.rs b/platform/ohos/backend/src/window.rs index 4b763911..eddceb47 100644 --- a/platform/ohos/backend/src/window.rs +++ b/platform/ohos/backend/src/window.rs @@ -330,11 +330,20 @@ impl i_slint_core::window::WindowAdapterInternal for OhosWindow { /// Editable fields ask for the platform input method here; the IME module /// forwards enable/disable to the ArkTS hooks. fn input_method_request(&self, request: i_slint_core::window::InputMethodRequest) { + use i_slint_core::items::InputType; match request { - i_slint_core::window::InputMethodRequest::Enable(_) => crate::ime::request(true), - i_slint_core::window::InputMethodRequest::Disable => crate::ime::request(false), - // Cursor/selection updates: the system keyboard tracks its own - // caret through the controller, nothing to forward yet. + i_slint_core::window::InputMethodRequest::Enable(properties) => { + crate::ime::request(true, matches!(properties.input_type, InputType::Password)) + } + i_slint_core::window::InputMethodRequest::Update(properties) => { + crate::ime::update_cursor( + properties.cursor_rect_origin.x, + properties.cursor_rect_origin.y, + properties.cursor_rect_size.width, + properties.cursor_rect_size.height, + ) + } + i_slint_core::window::InputMethodRequest::Disable => crate::ime::request(false, false), _ => {} } } diff --git a/platform/ohos/mail/src/bridge.rs b/platform/ohos/mail/src/bridge.rs index 07a03d84..6fd76e5f 100644 --- a/platform/ohos/mail/src/bridge.rs +++ b/platform/ohos/mail/src/bridge.rs @@ -28,9 +28,8 @@ static PICK_OPEN: AtomicUsize = AtomicUsize::new(0); static PICK_SAVE: AtomicUsize = AtomicUsize::new(0); static SHOW_IME: AtomicUsize = AtomicUsize::new(0); static HIDE_IME: AtomicUsize = AtomicUsize::new(0); +static IME_UPDATE_CURSOR: AtomicUsize = AtomicUsize::new(0); static REQUEST_PASTEBOARD: AtomicUsize = AtomicUsize::new(0); -static OPEN_PRIVACY: AtomicUsize = AtomicUsize::new(0); -static WITHDRAW_PRIVACY: AtomicUsize = AtomicUsize::new(0); /// Request ids for the callback-based picker flows. static NEXT_REQUEST: AtomicI64 = AtomicI64::new(1); @@ -86,27 +85,20 @@ pub(super) unsafe extern "C" fn set_bridge( install_property(env, argv[0], b"openUrl\0", &OPEN_URL, call_open_url); install_property(env, argv[0], b"pickOpen\0", &PICK_OPEN, call_pick_open); install_property(env, argv[0], b"pickSave\0", &PICK_SAVE, call_pick_save); - install_property(env, argv[0], b"showIme\0", &SHOW_IME, call_zero_arg); + install_property(env, argv[0], b"showIme\0", &SHOW_IME, call_bool_arg); install_property(env, argv[0], b"hideIme\0", &HIDE_IME, call_zero_arg); install_property( env, argv[0], - b"requestPasteboard\0", - &REQUEST_PASTEBOARD, - call_zero_arg, + b"imeUpdateCursor\0", + &IME_UPDATE_CURSOR, + call_cursor_update, ); install_property( env, argv[0], - b"openPrivacyPolicy\0", - &OPEN_PRIVACY, - call_privacy, - ); - install_property( - env, - argv[0], - b"withdrawPrivacyConsent\0", - &WITHDRAW_PRIVACY, + b"requestPasteboard\0", + &REQUEST_PASTEBOARD, call_zero_arg, ); undefined(env) @@ -168,23 +160,6 @@ pub(super) unsafe extern "C" fn submit_bridge_result( /// Open `url` in the system browser. Callable from any thread. pub(super) fn open_url(url: &str) -> Result<(), String> { - // The privacy policy ships as a local page until the website edition - // covers the application-specific disclosures; keep the URL in the shared - // UI so other hosts open the published policy. The shared UI appends the - // configured language as `?lang=`. - const PRIVACY_PREFIX: &str = "https://flectar.com/legal/privacy/"; - if url.starts_with(PRIVACY_PREFIX) { - if url.contains("consent=withdraw") { - return call_closure(&WITHDRAW_PRIVACY, "withdrawPrivacyConsent", ()); - } - let language = url - .split_once("?lang=") - .map(|(_, rest)| rest.split('&').next().unwrap_or("")) - .filter(|value| !value.is_empty()) - .unwrap_or("system") - .to_owned(); - return call_closure(&OPEN_PRIVACY, "openPrivacyPolicy", language); - } let url = CString::new(url).map_err(|_| "authorization address is malformed".to_owned())?; call_closure(&OPEN_URL, "openUrl", url) } @@ -234,6 +209,14 @@ pub(super) async fn pick_save( Ok((!path.is_empty()).then_some(path)) } +/// Arguments of one `imeUpdateCursor` call. +struct CursorUpdate { + left: f32, + top: f32, + width: f32, + height: f32, +} + /// Arguments of one `pickSave` call, boxed into the thread-safe-function data. struct SaveRequest { id: i64, @@ -252,8 +235,8 @@ async fn await_result( } /// Raise the system keyboard through the ArkTS hook. Callable from any thread. -pub(super) fn show_ime() { - let _ = call_closure(&SHOW_IME, "showIme", ()); +pub(super) fn show_ime(password: bool) { + let _ = call_closure(&SHOW_IME, "showIme", password); } /// Lower the system keyboard through the ArkTS hook. @@ -261,6 +244,20 @@ pub(super) fn hide_ime() { let _ = call_closure(&HIDE_IME, "hideIme", ()); } +/// Forward the caret rectangle to the ArkTS input-method controller. +pub(super) fn update_cursor(left: f32, top: f32, width: f32, height: f32) { + let _ = call_closure( + &IME_UPDATE_CURSOR, + "imeUpdateCursor", + CursorUpdate { + left, + top, + width, + height, + }, + ); +} + /// Ask the page to request the pasteboard permission (called from the /// clipboard fallback on a denied read). pub(super) fn request_pasteboard() { @@ -341,10 +338,10 @@ pub(super) unsafe extern "C" fn ime_function( core::ptr::null_mut(), ); if rc == ffi::NAPI_OK && argc >= 1 { - // FunctionKey.ENTER is 1 in the OHOS input-method enums. - if read_int64(env, argv[0]).unwrap_or(0) == 1 { - slint_ohos_backend::ime::function_key_enter(); - } + // `sendFunctionKey` fires only for the keyboard's action key. The + // reported EnterKeyType varies by keyboard and field, so any of + // them means "activate": send Return. + slint_ohos_backend::ime::function_key_enter(); } undefined(env) } @@ -493,38 +490,65 @@ unsafe extern "C" fn call_pick_save( } } -/// JS-thread half of [`show_ime`] and [`hide_ime`] (zero arguments). -unsafe extern "C" fn call_zero_arg( +/// JS-thread half of [`show_ime`] (one boolean: password field). +unsafe extern "C" fn call_bool_arg( env: napi_env, js_callback: napi_value, _context: *mut c_void, data: *mut c_void, ) { - // SAFETY: `data` is the `()` payload boxed by `call_closure`; this callback - // owns it. - let _payload = unsafe { Box::from_raw(data.cast::<()>()) }; - // SAFETY: NAPI call on the JS thread. - unsafe { call_with_args(env, js_callback, &[], "notice") }; + // SAFETY: `data` is the `bool` payload boxed by `call_closure`; this + // callback owns it. + let password = unsafe { Box::from_raw(data.cast::()) }; + let mut value: napi_value = core::ptr::null_mut(); + // SAFETY: NAPI call on the JS thread with a live boolean payload. + let rc = unsafe { ffi::napi_get_boolean(env, *password, &mut value) }; + if rc == ffi::NAPI_OK { + // SAFETY: NAPI call on the JS thread with live handles. + unsafe { call_with_args(env, js_callback, &[value], "showIme") }; + } } -/// JS-thread half of [`open_privacy_policy`] (one language argument). -unsafe extern "C" fn call_privacy( +/// JS-thread half of [`update_cursor`] (four doubles). +unsafe extern "C" fn call_cursor_update( env: napi_env, js_callback: napi_value, _context: *mut c_void, data: *mut c_void, ) { - // SAFETY: `data` is the `String` boxed by `call_closure`; this callback - // owns it. - let language = unsafe { Box::from_raw(data.cast::()) }; - // SAFETY: NAPI calls on the JS thread with live handles; the string bytes - // outlive the call. - unsafe { - let Some(arg) = string_value(env, language.as_bytes()) else { + // SAFETY: `data` is the `CursorUpdate` payload boxed by `call_closure`; + // this callback owns it. + let update = unsafe { Box::from_raw(data.cast::()) }; + let mut args: [napi_value; 4] = [core::ptr::null_mut(); 4]; + let values = [ + update.left as f64, + update.top as f64, + update.width as f64, + update.height as f64, + ]; + for (index, value) in values.into_iter().enumerate() { + // SAFETY: NAPI call on the JS thread; `args` outlives the call. + let rc = unsafe { ffi::napi_create_double(env, value, &mut args[index]) }; + if rc != ffi::NAPI_OK { return; - }; - call_with_args(env, js_callback, &[arg], "openPrivacyPolicy"); + } } + // SAFETY: NAPI call on the JS thread with live handles. + unsafe { call_with_args(env, js_callback, &args, "imeUpdateCursor") }; +} + +/// JS-thread half of [`hide_ime`] and [`request_pasteboard`] (zero arguments). +unsafe extern "C" fn call_zero_arg( + env: napi_env, + js_callback: napi_value, + _context: *mut c_void, + data: *mut c_void, +) { + // SAFETY: `data` is the `()` payload boxed by `call_closure`; this callback + // owns it. + let _payload = unsafe { Box::from_raw(data.cast::<()>()) }; + // SAFETY: NAPI call on the JS thread. + unsafe { call_with_args(env, js_callback, &[], "notice") }; } unsafe fn string_value(env: napi_env, bytes: &[u8]) -> Option { diff --git a/platform/ohos/mail/src/lib.rs b/platform/ohos/mail/src/lib.rs index b2452ad0..26f0202a 100644 --- a/platform/ohos/mail/src/lib.rs +++ b/platform/ohos/mail/src/lib.rs @@ -286,7 +286,7 @@ fn run_mail() -> std::result::Result<(), Box = Arc::new(documents::OhosDocuments); // Soft-keyboard control for Slint's editable fields. - slint_ohos_backend::ime::set_hooks(bridge::show_ime, bridge::hide_ime); + slint_ohos_backend::ime::set_hooks(bridge::show_ime, bridge::hide_ime, bridge::update_cursor); // A denied paste asks for the pasteboard ACL permission once (see clipboard.rs). slint_ohos_backend::clipboard::set_permission_hook(bridge::request_pasteboard); let mut platform = diff --git a/platform/ohos/shell/page-templates/Index.mail.ets b/platform/ohos/shell/page-templates/Index.mail.ets index a44a9691..1c288ca5 100644 --- a/platform/ohos/shell/page-templates/Index.mail.ets +++ b/platform/ohos/shell/page-templates/Index.mail.ets @@ -12,7 +12,8 @@ interface FlectarBridge { openUrl: (url: string) => void; pickOpen: (requestId: number) => void; pickSave: (requestId: number, sourcePath: string, suggestedName: string) => void; - showIme: () => void; + showIme: (password: boolean) => void; + imeUpdateCursor: (left: number, top: number, width: number, height: number) => void; hideIme: () => void; requestPasteboard: () => void; } @@ -31,6 +32,7 @@ interface FlectarNativeModule { struct Index { private nativeModule: FlectarNativeModule | undefined = undefined; private imeAttached: boolean = false; + private imePassword: boolean | undefined = undefined; private pasteboardPermissionRequested: boolean = false; aboutToAppear(): void { this.probeResources(); @@ -44,11 +46,13 @@ struct Index { const controller = inputMethod.getController(); controller.off('insertText'); controller.off('deleteLeft'); + controller.off('sendFunctionKey'); } catch (error) { hilog.error(0x0000, LOG_TAG, 'IME listener cleanup failed: %{public}s', (error as BusinessError).message); } this.imeAttached = false; + this.imePassword = undefined; } build(): void { @@ -112,8 +116,11 @@ struct Index { pickSave: (requestId: number, sourcePath: string, suggestedName: string): void => { this.pickSave(requestId, sourcePath, suggestedName); }, - showIme: (): void => { - this.showIme(); + showIme: (password: boolean): void => { + this.showIme(password); + }, + imeUpdateCursor: (left: number, top: number, width: number, height: number): void => { + this.imeUpdateCursor(left, top, width, height); }, hideIme: (): void => { this.hideIme(); @@ -211,11 +218,13 @@ struct Index { // Slint enables the input method when an editable field gains focus. The // controller refuses event subscription until it is attached, so attach - // first and register the text callbacks once it succeeds. - private showIme(): void { + // first and register the text callbacks once it succeeds. A password field + // changes the keyboard type, which the controller only reads at attach + // time, so a type change detaches and attaches again. + private showIme(password: boolean): void { try { const controller = inputMethod.getController(); - if (this.imeAttached) { + if (this.imeAttached && this.imePassword === password) { controller.showTextInput(inputMethod.RequestKeyboardReason.TOUCH) .catch((error: BusinessError): void => { hilog.error(0x0000, LOG_TAG, 'IME show failed: %{public}d %{public}s', @@ -225,15 +234,30 @@ struct Index { // left the foreground. Clear the flag and attach again so the // keyboard can come up on the next focus. this.imeAttached = false; - this.showIme(); + this.showIme(password); } }); return; } + if (this.imeAttached) { + controller.detach().then((): void => { + this.imeAttached = false; + this.imePassword = undefined; + this.showIme(password); + }).catch((error: BusinessError): void => { + hilog.error(0x0000, LOG_TAG, 'IME detach failed: %{public}d %{public}s', + error.code, error.message); + }); + return; + } const config: inputMethod.TextConfig = { inputAttribute: { - textInputType: inputMethod.TextInputType.TEXT, - enterKeyType: inputMethod.EnterKeyType.NEWLINE, + textInputType: password + ? inputMethod.TextInputType.VISIBLE_PASSWORD + : inputMethod.TextInputType.TEXT, + enterKeyType: password + ? inputMethod.EnterKeyType.DONE + : inputMethod.EnterKeyType.NEWLINE, }, }; // The touch reason asks the IME service to raise the soft keyboard even @@ -241,16 +265,21 @@ struct Index { // suppress it). controller.attach(true, config, inputMethod.RequestKeyboardReason.TOUCH).then((): void => { this.imeAttached = true; + this.imePassword = password; // (Re-)register exactly one set of handlers: a stale registration from // an earlier attach (or a duplicate one) would double-insert text. controller.off('insertText'); controller.off('deleteLeft'); + controller.off('sendFunctionKey'); controller.on('insertText', (text: string): void => { this.nativeModule?.imeCommit?.(text); }); controller.on('deleteLeft', (length: number): void => { this.nativeModule?.imeDelete?.(length); }); + controller.on('sendFunctionKey', (functionKey: inputMethod.FunctionKey): void => { + this.nativeModule?.imeFunction?.(functionKey.enterKeyType); + }); hilog.info(0x0000, LOG_TAG, 'IME attached'); }).catch((error: BusinessError): void => { hilog.error(0x0000, LOG_TAG, 'IME attach failed: %{public}d %{public}s', @@ -262,6 +291,28 @@ struct Index { } } + // Slint reports the caret rectangle while the field is focused. The system + // IME uses it to place the candidate window. + private imeUpdateCursor(left: number, top: number, width: number, height: number): void { + if (!this.imeAttached) { + return; + } + try { + const ui = this.getUIContext(); + inputMethod.getController().updateCursor({ + left: ui.vp2px(left), + top: ui.vp2px(top), + width: ui.vp2px(width), + height: ui.vp2px(height), + }).catch((error: BusinessError): void => { + hilog.error(0x0000, LOG_TAG, 'IME cursor update failed: %{public}d', error.code); + }); + } catch (error) { + hilog.error(0x0000, LOG_TAG, 'IME cursor update failed: %{public}s', + (error as BusinessError).message); + } + } + private hideIme(): void { if (!this.imeAttached) { return; From a9714fa1a7942648ed6f7c2258b73af32b129215 Mon Sep 17 00:00:00 2001 From: springmin Date: Sun, 25 Oct 2026 23:33:04 +0800 Subject: [PATCH 43/45] ohos: harden picker, fonts, input and system integration - chunk Asset Store secrets above its 1024-byte limit with generation-suffixed entries; publish the marker last and cover the chunked path in the self-test and unit tests - picker: copy asynchronously, cap imports at 50 MiB, stage under a random UUID (no picked filename in the cache), and let the native side report whether a late result was accepted so the page cleans up orphaned copies - register the native mouse callback for right-click/ContextMenuArea and reset Shift/CapsLock/auto-repeat state when the window hides or is destroyed - register non-Latin script fallbacks for the Slint UI and the email renderer from the platform font directory - report the system light/dark scheme through AppStorage, the NAPI bridge and SlintContext; throttle repeated IME cursor/color updates and guard overlapping IME attach calls - expose suspendPreview for the page lifecycle so preview resources are released --- platform/ohos/backend/src/asset.rs | 193 +++++++++++++++++- platform/ohos/backend/src/event.rs | 48 +++++ platform/ohos/backend/src/ffi.rs | 40 ++++ platform/ohos/backend/src/fonts.rs | 32 +++ platform/ohos/backend/src/lib.rs | 6 + platform/ohos/backend/src/platform.rs | 23 +++ platform/ohos/backend/src/window.rs | 25 ++- platform/ohos/backend/src/xcomponent.rs | 59 +++++- platform/ohos/mail/src/bridge.rs | 68 +++++- platform/ohos/mail/src/lib.rs | 8 + .../main/ets/entryability/EntryAbility.ets | 10 +- .../ohos/shell/page-templates/Index.mail.ets | 113 ++++++++-- src/renderer.rs | 27 +++ 13 files changed, 616 insertions(+), 36 deletions(-) diff --git a/platform/ohos/backend/src/asset.rs b/platform/ohos/backend/src/asset.rs index 23cc0303..8572a016 100644 --- a/platform/ohos/backend/src/asset.rs +++ b/platform/ohos/backend/src/asset.rs @@ -5,6 +5,8 @@ //! `ASSET_TAG_IS_PERSISTENT`), which requires no per-app permission. use core::ptr::null_mut; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; // Tag type bases (`Asset_TagType`). const ASSET_TYPE_NUMBER: u32 = 0x2 << 28; @@ -34,6 +36,16 @@ const ASSET_CRYPTO_ERROR: i32 = 24000009; /// kept as a single NUL, which no secret contains. const EMPTY_SECRET: &str = "\0"; +/// Asset Store rejects secrets longer than 1024 bytes. Longer credentials +/// (an OAuth bundle or a large API token) are split at UTF-8 boundaries and +/// stored under generation-suffixed aliases; the main alias keeps a small +/// marker that is written last. +const MAX_CHUNK_BYTES: usize = 1000; +const MAX_CHUNKS: usize = 64; +const CHUNK_MARKER: &str = "\0chunked:"; + +static CHUNK_GENERATION: AtomicU64 = AtomicU64::new(0); + #[repr(C)] #[derive(Clone, Copy)] struct AssetBlob { @@ -119,8 +131,8 @@ fn add(alias: &str, secret: &str) -> Result<(), i32> { if rc == ASSET_SUCCESS { Ok(()) } else { Err(rc) } } -/// Stores (or overwrites) `secret` under `alias`. -pub fn put(alias: &str, secret: &str) -> Result<(), String> { +/// Stores (or overwrites) one short secret under `alias`. +fn put_single(alias: &str, secret: &str) -> Result<(), String> { let secret = if secret.is_empty() { EMPTY_SECRET } else { @@ -151,8 +163,8 @@ pub fn put(alias: &str, secret: &str) -> Result<(), String> { add(alias, secret).map_err(|rc| format!("asset add failed: {rc}")) } -/// Loads the secret stored under `alias`. `Ok(None)` when absent. -pub fn get(alias: &str) -> Result, String> { +/// Loads one short secret stored under `alias`. `Ok(None)` when absent. +fn get_single(alias: &str) -> Result, String> { let alias_bytes = alias.as_bytes(); let query = [ bytes_attr(TAG_ALIAS, alias_bytes), @@ -201,9 +213,9 @@ pub fn get(alias: &str) -> Result, String> { })) } -/// Removes the secret stored under `alias`. Missing entries succeed +/// Removes one short secret stored under `alias`. Missing entries succeed /// (idempotent delete). -pub fn remove(alias: &str) -> Result<(), String> { +fn remove_single(alias: &str) -> Result<(), String> { let alias_bytes = alias.as_bytes(); let query = [bytes_attr(TAG_ALIAS, alias_bytes)]; // SAFETY: synchronous call; the query borrows `alias_bytes`. @@ -215,6 +227,134 @@ pub fn remove(alias: &str) -> Result<(), String> { } } +/// Splits a secret at UTF-8 boundaries into chunks no longer than `max` +/// bytes. A chunk always contains at least one character. +fn split_secret(secret: &str, max: usize) -> Vec { + let max = max.max(1); + let mut chunks = Vec::new(); + let mut current = String::new(); + for ch in secret.chars() { + if !current.is_empty() && current.len() + ch.len_utf8() > max { + chunks.push(std::mem::take(&mut current)); + } + current.push(ch); + } + if !current.is_empty() { + chunks.push(current); + } + chunks +} + +fn parse_chunk_marker(value: &str) -> Option<(String, usize)> { + let rest = value.strip_prefix(CHUNK_MARKER)?; + let (generation, count) = rest.split_once(':')?; + let count = count.parse().ok()?; + if generation.is_empty() || count == 0 || count > MAX_CHUNKS { + return None; + } + Some((generation.to_owned(), count)) +} + +fn chunk_alias(alias: &str, generation: &str, index: usize) -> String { + format!("{alias}:chunk:{generation}:{index}") +} + +fn new_generation() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let sequence = CHUNK_GENERATION.fetch_add(1, Ordering::Relaxed); + format!("{nanos:x}{sequence:x}") +} + +fn remove_chunks(alias: &str, generation: &str, count: usize) { + for index in 0..count { + let _ = remove_single(&chunk_alias(alias, generation, index)); + } +} + +/// Stores (or overwrites) `secret` under `alias`, chunking values the Asset +/// Store would reject for exceeding its 1024-byte limit. +pub fn put(alias: &str, secret: &str) -> Result<(), String> { + let secret = if secret.is_empty() { + EMPTY_SECRET + } else { + secret + }; + let old = get_single(alias).ok().flatten(); + let old_chunks = old.as_deref().and_then(parse_chunk_marker); + + if secret.len() <= MAX_CHUNK_BYTES { + put_single(alias, secret)?; + if let Some((generation, count)) = old_chunks { + remove_chunks(alias, &generation, count); + } + return Ok(()); + } + + let chunks = split_secret(secret, MAX_CHUNK_BYTES); + if chunks.len() > MAX_CHUNKS { + return Err(format!( + "asset secret is too large ({} chunks; limit {MAX_CHUNKS})", + chunks.len() + )); + } + let generation = new_generation(); + let mut written = 0; + for (index, chunk) in chunks.iter().enumerate() { + let chunk_key = chunk_alias(alias, &generation, index); + if let Err(error) = put_single(&chunk_key, chunk) { + for done in 0..written { + let _ = remove_single(&chunk_alias(alias, &generation, done)); + } + return Err(error); + } + written += 1; + } + // Publish the marker only after every chunk is durable. A failed marker + // write leaves the previous value readable and cleans the new chunks. + let marker = format!("{CHUNK_MARKER}{generation}:{}", chunks.len()); + if let Err(error) = put_single(alias, &marker) { + remove_chunks(alias, &generation, chunks.len()); + return Err(error); + } + if let Some((old_generation, old_count)) = old_chunks { + remove_chunks(alias, &old_generation, old_count); + } + Ok(()) +} + +/// Loads the secret stored under `alias`, reassembling a chunked value. +pub fn get(alias: &str) -> Result, String> { + let Some(value) = get_single(alias)? else { + return Ok(None); + }; + let Some((generation, count)) = parse_chunk_marker(&value) else { + return Ok(Some(value)); + }; + let mut secret = String::new(); + for index in 0..count { + let Some(chunk) = get_single(&chunk_alias(alias, &generation, index))? else { + return Err(format!( + "asset chunk {index}/{count} is missing for {alias}" + )); + }; + secret.push_str(&chunk); + } + Ok(Some(secret)) +} + +/// Removes the secret stored under `alias`, including any chunks. +pub fn remove(alias: &str) -> Result<(), String> { + if let Ok(Some(value)) = get_single(alias) + && let Some((generation, count)) = parse_chunk_marker(&value) + { + remove_chunks(alias, &generation, count); + } + remove_single(alias) +} + /// One `put`/`get`/`remove` cycle; returns `Ok` when the service works. pub fn self_test() -> Result<(), String> { const ALIAS: &str = "flectar:asset-self-test"; @@ -223,9 +363,44 @@ pub fn self_test() -> Result<(), String> { let loaded = get(ALIAS)?; let remove_result = remove(ALIAS); match (loaded.as_deref(), remove_result) { - (Some(value), Ok(())) if value == probe => Ok(()), - (Some(_), Ok(())) => Err("asset self-test returned different data".into()), + (Some(value), Ok(())) if value == probe => {} + (Some(_), Ok(())) => return Err("asset self-test returned different data".into()), + (_, Err(error)) => return Err(error), + (None, Ok(())) => return Err("asset self-test read back nothing".into()), + } + + // Exercise the chunked path with a value above the Asset Store limit. + const LONG_ALIAS: &str = "flectar:asset-self-test-long"; + let long_probe = "long-secret-".repeat(200); + put(LONG_ALIAS, &long_probe)?; + let loaded = get(LONG_ALIAS)?; + let remove_result = remove(LONG_ALIAS); + match (loaded.as_deref(), remove_result) { + (Some(value), Ok(())) if value == long_probe => Ok(()), + (Some(_), Ok(())) => Err("chunked asset self-test returned different data".into()), (_, Err(error)) => Err(error), - (None, Ok(())) => Err("asset self-test read back nothing".into()), + (None, Ok(())) => Err("chunked asset self-test read back nothing".into()), + } +} + +#[cfg(test)] +mod chunk_tests { + use super::*; + + #[test] + fn chunks_round_trip_at_utf8_boundaries() { + let secret = "é".repeat(1500); + let chunks = split_secret(&secret, MAX_CHUNK_BYTES); + assert!(chunks.len() > 1); + assert!(chunks.iter().all(|chunk| chunk.len() <= MAX_CHUNK_BYTES)); + assert_eq!(chunks.concat(), secret); + } + + #[test] + fn marker_round_trips() { + let marker = format!("{CHUNK_MARKER}abc:3"); + assert_eq!(parse_chunk_marker(&marker), Some(("abc".to_owned(), 3))); + assert_eq!(parse_chunk_marker("ordinary"), None); + assert_eq!(parse_chunk_marker(&format!("{CHUNK_MARKER}abc:0")), None); } } diff --git a/platform/ohos/backend/src/event.rs b/platform/ohos/backend/src/event.rs index 71a19ece..0beed290 100644 --- a/platform/ohos/backend/src/event.rs +++ b/platform/ohos/backend/src/event.rs @@ -37,6 +37,35 @@ pub enum TouchPhase { Cancel, } +/// Mouse action mapped from the XComponent mouse callback. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MouseAction { + Press, + Release, + Move, + Cancel, +} + +/// Mouse button mapped to Slint's pointer button. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MouseButton { + Left, + Right, + Middle, + Other, +} + +impl From for slint::platform::PointerEventButton { + fn from(button: MouseButton) -> Self { + match button { + MouseButton::Left => Self::Left, + MouseButton::Right => Self::Right, + MouseButton::Middle => Self::Middle, + MouseButton::Other => Self::Other, + } + } +} + /// Event delivered from the OHOS main thread (or a Slint proxy) to the Slint /// application thread. pub enum BackendEvent { @@ -66,6 +95,15 @@ pub enum BackendEvent { delta_x: f32, delta_y: f32, }, + /// The system color scheme changed (dark = true). + ColorScheme(bool), + /// A mouse event in physical surface coordinates. + Mouse { + x: f32, + y: f32, + action: MouseAction, + button: MouseButton, + }, /// A keyboard event translated to Slint's key-text representation. Key { text: SharedString, @@ -110,6 +148,16 @@ impl core::fmt::Debug for BackendEvent { } // The translated text is deliberately omitted: it may contain // characters typed into password fields. + BackendEvent::ColorScheme(dark) => write!(f, "ColorScheme({dark})"), + BackendEvent::Mouse { + x, + y, + action, + button, + } => write!( + f, + "Mouse {{ x: {x}, y: {y}, action: {action:?}, button: {button:?} }}" + ), BackendEvent::Key { down, repeat, .. } => { write!(f, "Key {{ down: {down}, repeat: {repeat} }}") } diff --git a/platform/ohos/backend/src/ffi.rs b/platform/ohos/backend/src/ffi.rs index 3a094095..17bf4f1f 100644 --- a/platform/ohos/backend/src/ffi.rs +++ b/platform/ohos/backend/src/ffi.rs @@ -111,6 +111,7 @@ unsafe extern "C" { pub fn napi_get_undefined(env: napi_env, result: *mut napi_value) -> napi_status; pub fn napi_create_int64(env: napi_env, value: i64, result: *mut napi_value) -> napi_status; pub fn napi_get_boolean(env: napi_env, value: bool, result: *mut napi_value) -> napi_status; + pub fn napi_get_value_bool(env: napi_env, value: napi_value, result: *mut bool) -> napi_status; pub fn napi_create_double(env: napi_env, value: f64, result: *mut napi_value) -> napi_status; pub fn napi_get_value_int64(env: napi_env, value: napi_value, result: *mut i64) -> napi_status; /// With `buf == NULL` and `bufsize == 0` the required length (excluding the @@ -396,6 +397,15 @@ unsafe extern "C" { component: *mut OH_NativeXComponent, callback: unsafe extern "C" fn(component: *mut OH_NativeXComponent, window: *mut c_void), ) -> i32; + pub fn OH_NativeXComponent_RegisterMouseEventCallback( + component: *mut OH_NativeXComponent, + callback: *mut OH_NativeXComponent_MouseEvent_Callback, + ) -> i32; + pub fn OH_NativeXComponent_GetMouseEvent( + component: *mut OH_NativeXComponent, + window: *mut c_void, + mouse_event: *mut OH_NativeXComponent_MouseEvent, + ) -> i32; pub fn OH_NativeXComponent_RegisterUIInputEventCallback( component: *mut OH_NativeXComponent, callback: unsafe extern "C" fn( @@ -600,3 +610,33 @@ mod tests { assert_eq!(size_of::(), 64); } } + +/// Mouse action mapped from `OH_NativeXComponent_MouseEventAction`. +pub const OH_NATIVEXCOMPONENT_MOUSE_PRESS: i32 = 1; +pub const OH_NATIVEXCOMPONENT_MOUSE_RELEASE: i32 = 2; +pub const OH_NATIVEXCOMPONENT_MOUSE_MOVE: i32 = 3; +pub const OH_NATIVEXCOMPONENT_MOUSE_CANCEL: i32 = 4; + +/// Mouse button values from `OH_NativeXComponent_MouseEventButton`. +pub const OH_NATIVEXCOMPONENT_LEFT_BUTTON: i32 = 0x01; +pub const OH_NATIVEXCOMPONENT_RIGHT_BUTTON: i32 = 0x02; +pub const OH_NATIVEXCOMPONENT_MIDDLE_BUTTON: i32 = 0x04; + +#[repr(C)] +pub struct OH_NativeXComponent_MouseEvent { + pub x: f32, + pub y: f32, + pub screenX: f32, + pub screenY: f32, + pub timestamp: i64, + pub action: i32, + pub button: i32, +} + +#[repr(C)] +pub struct OH_NativeXComponent_MouseEvent_Callback { + pub DispatchMouseEvent: + Option, + pub DispatchHoverEvent: + Option, +} diff --git a/platform/ohos/backend/src/fonts.rs b/platform/ohos/backend/src/fonts.rs index 4d41007a..d53fa493 100644 --- a/platform/ohos/backend/src/fonts.rs +++ b/platform/ohos/backend/src/fonts.rs @@ -66,6 +66,38 @@ pub fn register_system_fonts() -> usize { } } + // Fontconfig is unavailable on OHOS, so fontique starts with an empty + // fallback table. Every script that is not Latin must be given the system + // families explicitly; otherwise only Han text would fall back and Arabic, + // Hebrew, Thai, Indic, Cyrillic, Greek, Armenian, Georgian, Hangul and + // Japanese would render as tofu. + const FALLBACK_SCRIPTS: [&[u8; 4]; 25] = [ + b"Arab", b"Hebr", b"Thai", b"Laoo", b"Khmr", b"Mymr", b"Tibt", b"Deva", b"Beng", b"Guru", + b"Gujr", b"Orya", b"Taml", b"Telu", b"Knda", b"Mlym", b"Sinh", b"Ethi", b"Armn", b"Geor", + b"Cyrl", b"Grek", b"Hang", b"Hira", b"Kana", + ]; + let all_names: Vec = collection + .family_names() + .map(|name| name.to_string()) + .collect(); + let all_families: Vec<_> = all_names + .iter() + .filter_map(|name| collection.family_id(name)) + .collect(); + if !all_families.is_empty() { + for script in FALLBACK_SCRIPTS { + collection.append_fallbacks( + FallbackKey::new(Script::from_bytes(*script), None), + all_families.iter().copied(), + ); + } + crate::log::info(&format!( + "fonts: {} families registered for {} non-Latin script fallbacks", + all_families.len(), + FALLBACK_SCRIPTS.len() + )); + } + if han_families.is_empty() { return 0; } diff --git a/platform/ohos/backend/src/lib.rs b/platform/ohos/backend/src/lib.rs index 3f74af1a..a2f816fb 100644 --- a/platform/ohos/backend/src/lib.rs +++ b/platform/ohos/backend/src/lib.rs @@ -28,6 +28,7 @@ pub mod present; pub mod window; pub mod xcomponent; +use crate::event::BackendEvent; use core::ffi::c_void; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -84,3 +85,8 @@ pub unsafe fn register_xcomponent(component: *mut c_void) -> i32 { // SAFETY: the caller guarantees this is the live XComponent pointer. unsafe { xcomponent::register(component.cast()) } } + +/// Report the system light/dark preference from the ArkTS shell. +pub fn set_color_scheme(dark: bool) { + xcomponent::send(BackendEvent::ColorScheme(dark)); +} diff --git a/platform/ohos/backend/src/platform.rs b/platform/ohos/backend/src/platform.rs index a5d9b02d..322a53d6 100644 --- a/platform/ohos/backend/src/platform.rs +++ b/platform/ohos/backend/src/platform.rs @@ -169,6 +169,29 @@ impl OhosPlatform { w.dispatch_scroll(x, y, delta_x, delta_y); } } + BackendEvent::ColorScheme(dark) => { + if let Some(w) = self.window.borrow().as_ref() { + let scheme = if dark { + i_slint_core::items::ColorScheme::Dark + } else { + i_slint_core::items::ColorScheme::Light + }; + i_slint_core::window::WindowInner::from_pub(w.window()) + .context() + .set_color_scheme(scheme); + w.request_redraw(); + } + } + BackendEvent::Mouse { + x, + y, + action, + button, + } => { + if let Some(w) = self.window.borrow().as_ref() { + w.dispatch_mouse(x, y, action, button); + } + } BackendEvent::Key { text, down, repeat } => { if let Some(w) = self.window.borrow().as_ref() { w.dispatch_key(text, down, repeat); diff --git a/platform/ohos/backend/src/window.rs b/platform/ohos/backend/src/window.rs index eddceb47..60c9d4f3 100644 --- a/platform/ohos/backend/src/window.rs +++ b/platform/ohos/backend/src/window.rs @@ -18,7 +18,7 @@ use slint::{LogicalPosition, PhysicalSize, Window, WindowSize}; // internal `WindowAdapterInternal` hook). use i_slint_core::renderer::RendererSealed; -use crate::event::{BackendEvent, TouchPhase}; +use crate::event::{BackendEvent, MouseAction, MouseButton, TouchPhase}; use crate::gpu::GpuRenderer; use crate::present::PresentOutcome; @@ -247,6 +247,29 @@ impl OhosWindow { let _ = self.window.dispatch_event_with_result(event); } + /// Translate one mouse event into Slint pointer events. Right-button + /// press/release is what `ContextMenuArea` listens for. + pub fn dispatch_mouse(&self, x: f32, y: f32, action: MouseAction, button: MouseButton) { + let scale = { + let factor = self.window.scale_factor(); + if factor > 0.0 { factor } else { 1.0 } + }; + let position = LogicalPosition::new(x / scale, y / scale); + let event = match action { + MouseAction::Press => WindowEvent::PointerPressed { + position, + button: button.into(), + }, + MouseAction::Release => WindowEvent::PointerReleased { + position, + button: button.into(), + }, + MouseAction::Move => WindowEvent::PointerMoved { position }, + MouseAction::Cancel => WindowEvent::PointerExited, + }; + let _ = self.window.dispatch_event_with_result(event); + } + /// Translate one wheel/touchpad scroll into a Slint scroll event. /// /// The position is a physical XComponent coordinate and is scaled like a diff --git a/platform/ohos/backend/src/xcomponent.rs b/platform/ohos/backend/src/xcomponent.rs index a86472bc..97c0ddb8 100644 --- a/platform/ohos/backend/src/xcomponent.rs +++ b/platform/ohos/backend/src/xcomponent.rs @@ -12,7 +12,7 @@ use std::sync::mpsc::Sender; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; -use crate::event::{BackendEvent, SendPtr, TouchPhase}; +use crate::event::{BackendEvent, MouseAction, MouseButton, SendPtr, TouchPhase}; use crate::ffi; use crate::present::SurfaceGate; @@ -121,6 +121,18 @@ pub unsafe fn register(component: *mut ffi::OH_NativeXComponent) -> i32 { let rc_key = unsafe { ffi::OH_NativeXComponent_RegisterKeyEventCallback(component, on_key_event) }; crate::log::info(&format!("register XComponent key callback: rc={rc_key}")); + // Right-click and hover reach the native component only through this + // callback; without it Slint's ContextMenuArea never sees a right button. + let mouse_callbacks: &'static mut ffi::OH_NativeXComponent_MouseEvent_Callback = + Box::leak(Box::new(ffi::OH_NativeXComponent_MouseEvent_Callback { + DispatchMouseEvent: Some(dispatch_mouse_event), + DispatchHoverEvent: None, + })); + let rc_mouse = + unsafe { ffi::OH_NativeXComponent_RegisterMouseEventCallback(component, mouse_callbacks) }; + crate::log::info(&format!( + "register XComponent mouse callback: rc={rc_mouse}" + )); // SAFETY: same live component; the callbacks have the required C signature. let rc_show = unsafe { ffi::OH_NativeXComponent_RegisterSurfaceShowCallback(component, on_surface_show) }; @@ -183,9 +195,20 @@ unsafe extern "C" fn on_surface_hide( if let Some(visible) = VISIBLE.get() { visible.store(false, Ordering::Relaxed); } + reset_modifiers(); send(BackendEvent::SurfaceHidden); } +/// Forget modifier and auto-repeat state when the window loses focus. A key-up +/// delivered to another window would otherwise leave Shift/CapsLock stuck. +fn reset_modifiers() { + KEY_SHIFT.store(false, Ordering::Relaxed); + KEY_CAPS_LOCK.store(false, Ordering::Relaxed); + if let Ok(mut keys) = keys_down().lock() { + keys.clear(); + } +} + fn surface_event(component: *mut ffi::OH_NativeXComponent, window: *mut c_void) { if window.is_null() { return; @@ -217,6 +240,7 @@ unsafe extern "C" fn on_surface_destroyed( let Some(gate) = GATE.get().cloned() else { return; }; + reset_modifiers(); let live = gate.begin_destroy(); send(BackendEvent::SurfaceDestroyed); if live { @@ -261,6 +285,39 @@ unsafe extern "C" fn dispatch_touch_event( }); } +unsafe extern "C" fn dispatch_mouse_event( + component: *mut ffi::OH_NativeXComponent, + window: *mut c_void, +) { + if let Some(visible) = VISIBLE.get() { + visible.store(true, Ordering::Relaxed); + } + let mut mouse: ffi::OH_NativeXComponent_MouseEvent = unsafe { core::mem::zeroed() }; + let rc = unsafe { ffi::OH_NativeXComponent_GetMouseEvent(component, window, &mut mouse) }; + if rc != 0 { + return; + } + let action = match mouse.action { + ffi::OH_NATIVEXCOMPONENT_MOUSE_PRESS => MouseAction::Press, + ffi::OH_NATIVEXCOMPONENT_MOUSE_RELEASE => MouseAction::Release, + ffi::OH_NATIVEXCOMPONENT_MOUSE_MOVE => MouseAction::Move, + ffi::OH_NATIVEXCOMPONENT_MOUSE_CANCEL => MouseAction::Cancel, + _ => return, + }; + let button = match mouse.button { + ffi::OH_NATIVEXCOMPONENT_LEFT_BUTTON => MouseButton::Left, + ffi::OH_NATIVEXCOMPONENT_RIGHT_BUTTON => MouseButton::Right, + ffi::OH_NATIVEXCOMPONENT_MIDDLE_BUTTON => MouseButton::Middle, + _ => MouseButton::Other, + }; + send(BackendEvent::Mouse { + x: mouse.x, + y: mouse.y, + action, + button, + }); +} + /// Wheel and touchpad scrolling arrives only through the XComponent UI input /// callback; the touch callback never sees axis events. unsafe extern "C" fn on_axis_event( diff --git a/platform/ohos/mail/src/bridge.rs b/platform/ohos/mail/src/bridge.rs index 6fd76e5f..6da8c794 100644 --- a/platform/ohos/mail/src/bridge.rs +++ b/platform/ohos/mail/src/bridge.rs @@ -126,7 +126,7 @@ pub(super) unsafe extern "C" fn submit_bridge_result( ); if rc != ffi::NAPI_OK || argc < 1 { slint_ohos_backend::ohos_error!("mail: submitBridgeResult: bad arguments (rc={rc})"); - return undefined(env); + return boolean(env, false); } let id = read_int64(env, argv[0]).unwrap_or(0); let path = if argc > 1 { @@ -140,7 +140,7 @@ pub(super) unsafe extern "C" fn submit_bridge_result( String::new() }; let request = pending().lock().ok().and_then(|mut map| map.remove(&id)); - match request { + let accepted = match request { Some(Pending { sender, .. }) => { // Empty path + empty error means "the user cancelled". let outcome = if !path.is_empty() || error.is_empty() { @@ -148,16 +148,30 @@ pub(super) unsafe extern "C" fn submit_bridge_result( } else { Err(error) }; - let _ = sender.send(outcome); + sender.send(outcome).is_ok() } - None => slint_ohos_backend::ohos_info!( - "mail: bridge result for an unknown request id (dropped)" - ), - } - undefined(env) + None => { + slint_ohos_backend::ohos_info!( + "mail: bridge result for an unknown request id (dropped)" + ); + false + } + }; + // The page deletes a staged import when the request timed out or was + // cancelled before the result arrived; otherwise it would leak a copy + // in the cache directory. + boolean(env, accepted) } } +/// Create a JS boolean return value for a NAPI callback. +unsafe fn boolean(env: napi_env, value: bool) -> napi_value { + let mut result: napi_value = core::ptr::null_mut(); + // SAFETY: NAPI call on the JS thread. + unsafe { ffi::napi_get_boolean(env, value, &mut result) }; + result +} + /// Open `url` in the system browser. Callable from any thread. pub(super) fn open_url(url: &str) -> Result<(), String> { let url = CString::new(url).map_err(|_| "authorization address is malformed".to_owned())?; @@ -320,6 +334,44 @@ pub(super) unsafe extern "C" fn ime_delete( } } +/// `setColorScheme(dark)`: report the system light/dark preference. +pub(super) unsafe extern "C" fn set_color_scheme( + env: napi_env, + info: ffi::napi_callback_info, +) -> napi_value { + // SAFETY: live NAPI env/info; argv holds one boolean. + unsafe { + let mut argc: usize = 1; + let mut argv: [napi_value; 1] = [core::ptr::null_mut()]; + if ffi::napi_get_cb_info( + env, + info, + &mut argc, + argv.as_mut_ptr(), + core::ptr::null_mut(), + core::ptr::null_mut(), + ) == ffi::NAPI_OK + && argc >= 1 + { + let mut dark = false; + if ffi::napi_get_value_bool(env, argv[0], &mut dark) == ffi::NAPI_OK { + slint_ohos_backend::set_color_scheme(dark); + } + } + undefined(env) + } +} + +/// `suspendPreview()`: the app is leaving the foreground; release any preview +/// resource the core keeps alive. +pub(super) unsafe extern "C" fn suspend_preview( + env: napi_env, + _info: ffi::napi_callback_info, +) -> napi_value { + flectar_mail::suspend_file_preview(); + undefined(env) +} + /// `imeFunction(key)`: the action key of the soft keyboard was pressed. pub(super) unsafe extern "C" fn ime_function( env: napi_env, diff --git a/platform/ohos/mail/src/lib.rs b/platform/ohos/mail/src/lib.rs index 26f0202a..9d0e1c9b 100644 --- a/platform/ohos/mail/src/lib.rs +++ b/platform/ohos/mail/src/lib.rs @@ -188,6 +188,14 @@ unsafe fn register_module_functions(env: napi_env, exports: napi_value) { &b"imeFunction\0"[..], bridge::ime_function as ffi::napi_callback, ), + ( + &b"setColorScheme\0"[..], + bridge::set_color_scheme as ffi::napi_callback, + ), + ( + &b"suspendPreview\0"[..], + bridge::suspend_preview as ffi::napi_callback, + ), ] { let mut function: napi_value = core::ptr::null_mut(); let rc = ffi::napi_create_function( diff --git a/platform/ohos/shell/entry/src/main/ets/entryability/EntryAbility.ets b/platform/ohos/shell/entry/src/main/ets/entryability/EntryAbility.ets index 18b9c9aa..72da6e38 100644 --- a/platform/ohos/shell/entry/src/main/ets/entryability/EntryAbility.ets +++ b/platform/ohos/shell/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,4 +1,4 @@ -import { UIAbility } from '@kit.AbilityKit'; +import { Configuration, ConfigurationConstant, UIAbility } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; @@ -6,6 +6,14 @@ import { BusinessError } from '@kit.BasicServicesKit'; const TAG = 'FlectarOhos'; export default class EntryAbility extends UIAbility { + onConfigurationUpdate(newConfig: Configuration): void { + // The ArkUI page watches this AppStorage value and reports the new + // light/dark preference to Slint. + AppStorage.setOrCreate( + 'flectarColorMode', + newConfig.colorMode ?? ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + } + onWindowStageCreate(windowStage: window.WindowStage): void { windowStage.loadContent('pages/Index', (err: BusinessError) => { if (err.code) { diff --git a/platform/ohos/shell/page-templates/Index.mail.ets b/platform/ohos/shell/page-templates/Index.mail.ets index 1c288ca5..8d200201 100644 --- a/platform/ohos/shell/page-templates/Index.mail.ets +++ b/platform/ohos/shell/page-templates/Index.mail.ets @@ -1,10 +1,14 @@ -import { abilityAccessCtrl, common, PermissionRequestResult, Permissions, Want } from '@kit.AbilityKit'; +import { abilityAccessCtrl, common, ConfigurationConstant, PermissionRequestResult, Permissions, Want } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { fileIo, picker } from '@kit.CoreFileKit'; import { inputMethod } from '@kit.IMEKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; +import { util } from '@kit.ArkTS'; const LOG_TAG = 'FlectarOhos'; +// Keep picker imports bounded: a mail attachment is copied into the app +// cache, so a mis-picked multi-GB file must not fill the sandbox. +const MAX_IMPORT_BYTES = 50 * 1024 * 1024; // Services the native module invokes from Rust; the closures run on the ArkTS // UI thread through a thread-safe function. `pickOpen`/`pickSave` are // request/response: the page reports the outcome through `submitBridgeResult`. @@ -21,21 +25,47 @@ interface FlectarBridge { // The exports of the native XComponent module (`onLoad` hands them over). interface FlectarNativeModule { setBridge?: (bridge: FlectarBridge) => void; - submitBridgeResult?: (requestId: number, path: string, error: string) => void; + submitBridgeResult?: (requestId: number, path: string, error: string) => boolean; imeCommit?: (text: string) => void; imeDelete?: (count: number) => void; imeFunction?: (key: number) => void; + setColorScheme?: (dark: boolean) => void; + suspendPreview?: () => void; } @Entry @Component struct Index { private nativeModule: FlectarNativeModule | undefined = undefined; + @StorageLink('flectarColorMode') @Watch('onColorModeChanged') colorMode: number = + ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; private imeAttached: boolean = false; + private imeAttachPending: boolean = false; private imePassword: boolean | undefined = undefined; + private lastImeCursor: string = ''; + private appliedColorMode: number = -2; private pasteboardPermissionRequested: boolean = false; aboutToAppear(): void { this.probeResources(); + try { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + AppStorage.setOrCreate('flectarColorMode', context.config.colorMode ?? -1); + } catch (error) { + hilog.error(0x0000, LOG_TAG, 'could not read the system color mode: %{public}s', + (error as BusinessError).message); + } + } + + onColorModeChanged(): void { + this.applySystemColorScheme(this.colorMode); + } + + onPageShow(): void { + this.applySystemColorScheme(this.colorMode); + } + + onPageHide(): void { + this.nativeModule?.suspendPreview?.(); } // The IME controller is process-scoped; its listeners capture this page, so @@ -52,7 +82,9 @@ struct Index { (error as BusinessError).message); } this.imeAttached = false; + this.imeAttachPending = false; this.imePassword = undefined; + this.lastImeCursor = ''; } build(): void { @@ -129,10 +161,11 @@ struct Index { this.requestPasteboardPermission(); }, }); + this.applySystemColorScheme(this.colorMode); } - private reportBridgeResult(requestId: number, path: string, error: string): void { - this.nativeModule?.submitBridgeResult?.(requestId, path, error); + private reportBridgeResult(requestId: number, path: string, error: string): boolean { + return this.nativeModule?.submitBridgeResult?.(requestId, path, error) ?? false; } private openUrl(url: string): void { @@ -172,9 +205,16 @@ struct Index { this.reportBridgeResult(requestId, '', ''); return; } - const staged = this.stageCopy(context, uris[0]); + const staged = await this.stageCopy(context, uris[0]); hilog.info(0x0000, LOG_TAG, 'picker imported a document (%{public}d picked)', uris.length); - this.reportBridgeResult(requestId, staged, ''); + if (!this.reportBridgeResult(requestId, staged, '')) { + // The native request timed out or was cancelled while the picker was + // open; do not leave the staged copy behind. + fileIo.unlink(staged).catch((error: BusinessError): void => { + hilog.error(0x0000, LOG_TAG, 'could not remove the orphaned import: %{public}s', + error.message); + }); + } } catch (error) { const failure = error as BusinessError; hilog.error(0x0000, LOG_TAG, 'document picker failed: %{public}d %{public}s', @@ -223,6 +263,9 @@ struct Index { // time, so a type change detaches and attaches again. private showIme(password: boolean): void { try { + if (this.imeAttachPending) { + return; + } const controller = inputMethod.getController(); if (this.imeAttached && this.imePassword === password) { controller.showTextInput(inputMethod.RequestKeyboardReason.TOUCH) @@ -240,11 +283,15 @@ struct Index { return; } if (this.imeAttached) { + this.imeAttachPending = true; controller.detach().then((): void => { this.imeAttached = false; + this.imeAttachPending = false; this.imePassword = undefined; + this.lastImeCursor = ''; this.showIme(password); }).catch((error: BusinessError): void => { + this.imeAttachPending = false; hilog.error(0x0000, LOG_TAG, 'IME detach failed: %{public}d %{public}s', error.code, error.message); }); @@ -263,9 +310,12 @@ struct Index { // The touch reason asks the IME service to raise the soft keyboard even // when a physical keyboard is attached (a 2in1 policy would otherwise // suppress it). + this.imeAttachPending = true; controller.attach(true, config, inputMethod.RequestKeyboardReason.TOUCH).then((): void => { this.imeAttached = true; + this.imeAttachPending = false; this.imePassword = password; + this.lastImeCursor = ''; // (Re-)register exactly one set of handlers: a stale registration from // an earlier attach (or a duplicate one) would double-insert text. controller.off('insertText'); @@ -282,6 +332,7 @@ struct Index { }); hilog.info(0x0000, LOG_TAG, 'IME attached'); }).catch((error: BusinessError): void => { + this.imeAttachPending = false; hilog.error(0x0000, LOG_TAG, 'IME attach failed: %{public}d %{public}s', error.code, error.message); }); @@ -297,6 +348,11 @@ struct Index { if (!this.imeAttached) { return; } + const key = `${left},${top},${width},${height}`; + if (key === this.lastImeCursor) { + return; + } + this.lastImeCursor = key; try { const ui = this.getUIContext(); inputMethod.getController().updateCursor({ @@ -331,6 +387,25 @@ struct Index { } } + private applySystemColorScheme(mode: number): void { + try { + let colorMode = mode; + if (colorMode < 0) { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + colorMode = context.config.colorMode ?? ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; + } + if (this.nativeModule === undefined || colorMode === this.appliedColorMode) { + return; + } + this.appliedColorMode = colorMode; + this.nativeModule.setColorScheme?.( + colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK); + } catch (error) { + hilog.error(0x0000, LOG_TAG, 'could not apply the system color mode: %{public}s', + (error as BusinessError).message); + } + } + private requestPasteboardPermission(): void { if (this.pasteboardPermissionRequested) { return; @@ -376,7 +451,7 @@ struct Index { * @throws { BusinessError } when the picked file cannot be opened or copied; * the caller reports it through the bridge result. */ - private stageCopy(context: common.UIAbilityContext, uri: string): string { + private async stageCopy(context: common.UIAbilityContext, uri: string): Promise { const directory = `${context.cacheDir}/imports`; try { fileIo.mkdirSync(directory, true); @@ -387,27 +462,33 @@ struct Index { const failure = error as BusinessError; hilog.info(0x0000, LOG_TAG, 'stage: mkdir reported %{public}d; continuing', failure.code); } - const segments = uri.split('/'); - const raw = segments.pop() ?? 'document'; - // The picker chooses the file name; keep it to a conservative character - // set and length so it cannot escape the cache directory. - const cleaned = raw.replace(/[^A-Za-z0-9._-]/g, '_').substring(0, 96); - const name = cleaned.length > 0 ? cleaned : 'document'; - const destination = `${directory}/${Date.now()}-${name}`; const source = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); try { + const size = fileIo.statSync(source.fd).size; + if (size > MAX_IMPORT_BYTES) { + throw new Error(`the selected file is too large (${size} bytes; limit ${MAX_IMPORT_BYTES})`); + } + // Never reuse the picked file name (or a predictable timestamp) in the + // cache: it can leak the user's document name and collide with another + // staged import. Keep only a short extension for content detection. + const raw = uri.split('/').pop() ?? ''; + const dot = raw.lastIndexOf('.'); + const extension = dot > 0 + ? raw.substring(dot).replace(/[^A-Za-z0-9.]/g, '').substring(0, 12) + : ''; + const destination = `${directory}/import-${util.generateRandomUUID(true)}${extension}`; const target = fileIo.openSync( destination, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC, ); try { - fileIo.copyFileSync(source.fd, target.fd); + await fileIo.copyFile(source.fd, target.fd); } finally { fileIo.closeSync(target); } + return destination; } finally { fileIo.closeSync(source); } - return destination; } } diff --git a/src/renderer.rs b/src/renderer.rs index a9608188..b7430495 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1658,6 +1658,33 @@ fn create_email_font_ctx_with_system_fonts(system_fonts: bool) -> parley::FontCo .collection .set_fallbacks(key, fallbacks.into_iter()); } + + // The same empty-Fontconfig problem affects every non-Latin script in + // mail content, not only Han. Give fontique the system families for + // the other scripts as well; the platform directory is the only source + // of these faces on OHOS. + const FALLBACK_SCRIPTS: [&[u8; 4]; 25] = [ + b"Arab", b"Hebr", b"Thai", b"Laoo", b"Khmr", b"Mymr", b"Tibt", b"Deva", + b"Beng", b"Guru", b"Gujr", b"Orya", b"Taml", b"Telu", b"Knda", b"Mlym", + b"Sinh", b"Ethi", b"Armn", b"Geor", b"Cyrl", b"Grek", b"Hang", b"Hira", + b"Kana", + ]; + let all_families: Vec<_> = names + .iter() + .filter_map(|name| font_ctx.collection.family_id(name)) + .collect(); + for script in FALLBACK_SCRIPTS { + let key = FallbackKey::new(Script::from_bytes(*script), None); + let mut fallbacks: Vec<_> = font_ctx.collection.fallback_families(key).collect(); + for family in &all_families { + if !fallbacks.contains(family) { + fallbacks.push(*family); + } + } + font_ctx + .collection + .set_fallbacks(key, fallbacks.into_iter()); + } } // Keep the host's preferred sans-serif families, then use the font that is From 062ba5da200b9f863c24e9d3da4edfa1fbc8f593 Mon Sep 17 00:00:00 2001 From: springmin Date: Mon, 26 Oct 2026 06:20:45 +0800 Subject: [PATCH 44/45] ohos: complete the P1 picker, IME and lifecycle fixes - use async fileIo open/stat/mkdir/close in both picker paths so slow content providers do not block the ArkUI thread - add the window origin to IME updateCursor coordinates, since CursorInfo is in absolute physical screen coordinates - subscribe the page to UIAbility.onBackground through the ability eventHub so preview resources are released on every background path, while keeping onPageHide as a fallback - cache the latest password/type request while an IME attach or detach is in flight and replay it when the transition finishes --- .../main/ets/entryability/EntryAbility.ets | 6 ++ .../ohos/shell/page-templates/Index.mail.ets | 78 +++++++++++++++---- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/platform/ohos/shell/entry/src/main/ets/entryability/EntryAbility.ets b/platform/ohos/shell/entry/src/main/ets/entryability/EntryAbility.ets index 72da6e38..63a72554 100644 --- a/platform/ohos/shell/entry/src/main/ets/entryability/EntryAbility.ets +++ b/platform/ohos/shell/entry/src/main/ets/entryability/EntryAbility.ets @@ -6,6 +6,12 @@ import { BusinessError } from '@kit.BasicServicesKit'; const TAG = 'FlectarOhos'; export default class EntryAbility extends UIAbility { + onBackground(): void { + // The page releases any preview resources when this event arrives; using + // the ability lifecycle covers background paths that onPageHide misses. + this.context.eventHub.emit('flectarBackground'); + } + onConfigurationUpdate(newConfig: Configuration): void { // The ArkUI page watches this AppStorage value and reports the new // light/dark preference to Slint. diff --git a/platform/ohos/shell/page-templates/Index.mail.ets b/platform/ohos/shell/page-templates/Index.mail.ets index 8d200201..049b6765 100644 --- a/platform/ohos/shell/page-templates/Index.mail.ets +++ b/platform/ohos/shell/page-templates/Index.mail.ets @@ -3,6 +3,7 @@ import { BusinessError } from '@kit.BasicServicesKit'; import { fileIo, picker } from '@kit.CoreFileKit'; import { inputMethod } from '@kit.IMEKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; +import { window } from '@kit.ArkUI'; import { util } from '@kit.ArkTS'; const LOG_TAG = 'FlectarOhos'; @@ -42,6 +43,12 @@ struct Index { private imeAttached: boolean = false; private imeAttachPending: boolean = false; private imePassword: boolean | undefined = undefined; + private pendingImePassword: boolean | undefined = undefined; + private windowOffsetX: number = 0; + private windowOffsetY: number = 0; + private appBackgroundHandler: () => void = (): void => { + this.nativeModule?.suspendPreview?.(); + }; private lastImeCursor: string = ''; private appliedColorMode: number = -2; private pasteboardPermissionRequested: boolean = false; @@ -50,6 +57,7 @@ struct Index { try { const context = this.getUIContext().getHostContext() as common.UIAbilityContext; AppStorage.setOrCreate('flectarColorMode', context.config.colorMode ?? -1); + context.eventHub.on('flectarBackground', this.appBackgroundHandler); } catch (error) { hilog.error(0x0000, LOG_TAG, 'could not read the system color mode: %{public}s', (error as BusinessError).message); @@ -62,6 +70,7 @@ struct Index { onPageShow(): void { this.applySystemColorScheme(this.colorMode); + this.updateWindowOffset(); } onPageHide(): void { @@ -81,9 +90,17 @@ struct Index { hilog.error(0x0000, LOG_TAG, 'IME listener cleanup failed: %{public}s', (error as BusinessError).message); } + try { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + context.eventHub.off('flectarBackground', this.appBackgroundHandler); + } catch (error) { + hilog.error(0x0000, LOG_TAG, 'background listener cleanup failed: %{public}s', + (error as BusinessError).message); + } this.imeAttached = false; this.imeAttachPending = false; this.imePassword = undefined; + this.pendingImePassword = undefined; this.lastImeCursor = ''; } @@ -162,6 +179,7 @@ struct Index { }, }); this.applySystemColorScheme(this.colorMode); + this.updateWindowOffset(); } private reportBridgeResult(requestId: number, path: string, error: string): boolean { @@ -235,16 +253,17 @@ struct Index { this.reportBridgeResult(requestId, '', ''); return; } - const destination = fileIo.openSync(uris[0], fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC); + const destination = await fileIo.open( + uris[0], fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC); try { - const source = fileIo.openSync(sourcePath, fileIo.OpenMode.READ_ONLY); + const source = await fileIo.open(sourcePath, fileIo.OpenMode.READ_ONLY); try { await fileIo.copyFile(source.fd, destination.fd); } finally { - fileIo.closeSync(source); + await fileIo.close(source); } } finally { - fileIo.closeSync(destination); + await fileIo.close(destination); } hilog.info(0x0000, LOG_TAG, 'picker exported a document'); this.reportBridgeResult(requestId, uris[0], ''); @@ -264,6 +283,7 @@ struct Index { private showIme(password: boolean): void { try { if (this.imeAttachPending) { + this.pendingImePassword = password; return; } const controller = inputMethod.getController(); @@ -289,9 +309,16 @@ struct Index { this.imeAttachPending = false; this.imePassword = undefined; this.lastImeCursor = ''; - this.showIme(password); + const pending = this.pendingImePassword; + this.pendingImePassword = undefined; + this.showIme(pending ?? password); }).catch((error: BusinessError): void => { this.imeAttachPending = false; + const pending = this.pendingImePassword; + this.pendingImePassword = undefined; + if (pending !== undefined) { + this.showIme(pending); + } hilog.error(0x0000, LOG_TAG, 'IME detach failed: %{public}d %{public}s', error.code, error.message); }); @@ -331,8 +358,18 @@ struct Index { this.nativeModule?.imeFunction?.(functionKey.enterKeyType); }); hilog.info(0x0000, LOG_TAG, 'IME attached'); + const pending = this.pendingImePassword; + this.pendingImePassword = undefined; + if (pending !== undefined && pending !== password) { + this.showIme(pending); + } }).catch((error: BusinessError): void => { this.imeAttachPending = false; + const pending = this.pendingImePassword; + this.pendingImePassword = undefined; + if (pending !== undefined && pending !== password) { + this.showIme(pending); + } hilog.error(0x0000, LOG_TAG, 'IME attach failed: %{public}d %{public}s', error.code, error.message); }); @@ -356,8 +393,8 @@ struct Index { try { const ui = this.getUIContext(); inputMethod.getController().updateCursor({ - left: ui.vp2px(left), - top: ui.vp2px(top), + left: this.windowOffsetX + ui.vp2px(left), + top: this.windowOffsetY + ui.vp2px(top), width: ui.vp2px(width), height: ui.vp2px(height), }).catch((error: BusinessError): void => { @@ -387,6 +424,21 @@ struct Index { } } + // CursorInfo is in absolute physical screen coordinates. The Slint caret + // rectangle is relative to the XComponent, so add the window origin. + private async updateWindowOffset(): Promise { + try { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const appWindow = await window.getLastWindow(context); + const rect = appWindow.getWindowProperties().windowRect; + this.windowOffsetX = rect.left; + this.windowOffsetY = rect.top; + } catch (error) { + hilog.error(0x0000, LOG_TAG, 'could not read the window origin: %{public}s', + (error as BusinessError).message); + } + } + private applySystemColorScheme(mode: number): void { try { let colorMode = mode; @@ -454,7 +506,7 @@ struct Index { private async stageCopy(context: common.UIAbilityContext, uri: string): Promise { const directory = `${context.cacheDir}/imports`; try { - fileIo.mkdirSync(directory, true); + await fileIo.mkdir(directory, true); } catch (error) { // OHOS's mkdir reports 13900015 when the directory already exists (the // previous import leaves it behind), unlike Node's recursive mkdir. That @@ -462,9 +514,9 @@ struct Index { const failure = error as BusinessError; hilog.info(0x0000, LOG_TAG, 'stage: mkdir reported %{public}d; continuing', failure.code); } - const source = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); + const source = await fileIo.open(uri, fileIo.OpenMode.READ_ONLY); try { - const size = fileIo.statSync(source.fd).size; + const size = (await fileIo.stat(source.fd)).size; if (size > MAX_IMPORT_BYTES) { throw new Error(`the selected file is too large (${size} bytes; limit ${MAX_IMPORT_BYTES})`); } @@ -477,18 +529,18 @@ struct Index { ? raw.substring(dot).replace(/[^A-Za-z0-9.]/g, '').substring(0, 12) : ''; const destination = `${directory}/import-${util.generateRandomUUID(true)}${extension}`; - const target = fileIo.openSync( + const target = await fileIo.open( destination, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC, ); try { await fileIo.copyFile(source.fd, target.fd); } finally { - fileIo.closeSync(target); + await fileIo.close(target); } return destination; } finally { - fileIo.closeSync(source); + await fileIo.close(source); } } } From a0061c948e2b8fa2b76d228681646dcc0dd6e63d Mon Sep 17 00:00:00 2001 From: springmin Date: Mon, 26 Oct 2026 10:57:46 +0800 Subject: [PATCH 45/45] ohos: clarify the picker cache guard The 50 MiB constant only keeps an accidental huge selection out of the app cache; the shared core still enforces the 25 MiB total compose/draft attachment limit. --- platform/ohos/shell/page-templates/Index.mail.ets | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platform/ohos/shell/page-templates/Index.mail.ets b/platform/ohos/shell/page-templates/Index.mail.ets index 049b6765..01be7cc7 100644 --- a/platform/ohos/shell/page-templates/Index.mail.ets +++ b/platform/ohos/shell/page-templates/Index.mail.ets @@ -9,6 +9,8 @@ import { util } from '@kit.ArkTS'; const LOG_TAG = 'FlectarOhos'; // Keep picker imports bounded: a mail attachment is copied into the app // cache, so a mis-picked multi-GB file must not fill the sandbox. +// Per-file cache guard for the picker; the shared compose/draft limit is +// 25 MiB total across attachments on every platform. const MAX_IMPORT_BYTES = 50 * 1024 * 1024; // Services the native module invokes from Rust; the closures run on the ArkTS // UI thread through a thread-safe function. `pickOpen`/`pickSave` are