diff --git a/.github/workflows/build-core.yml b/.github/workflows/build-core.yml new file mode 100644 index 00000000..7745060d --- /dev/null +++ b/.github/workflows/build-core.yml @@ -0,0 +1,409 @@ +name: Build installable HarmonyOS HAP + +# Cross-compiles the mihomo/gVisor backend into proxy_core/libs/arm64-v8a/libflclash.so +# using the OpenHarmony NDK + the OHOS-patched Go toolchain (the one that understands +# the fork-only `-tlsmodegd` flag). Mirrors proxy_core/src/flclash/build.sh, but split +# into small, independently-failing steps so a break is easy to localize. The +# second job packages that core into a signed, verified HAP. + +on: + workflow_dispatch: {} + push: + paths: + - "AppScope/**" + - "entry/**" + - "proxy_core/**" + - "xb_components/**" + - "build-profile.json5" + - "oh-package.json5" + - "hvigor/**" + - "hvigorfile.ts" + - "scripts/ci/**" + - "docs/ci-hap-signing.md" + - ".github/workflows/build-core.yml" + pull_request: + paths: + - "AppScope/**" + - "entry/**" + - "proxy_core/**" + - "xb_components/**" + - "build-profile.json5" + - "oh-package.json5" + - "hvigor/**" + - "hvigorfile.ts" + - "scripts/ci/**" + - "docs/ci-hap-signing.md" + - ".github/workflows/build-core.yml" + +permissions: + contents: read + +concurrency: + group: build-ohos-core-${{ github.ref }} + cancel-in-progress: true + +env: + CORE_DIR: proxy_core/src/flclash + OHOS_SDK_VERSION: "5.0.0" # API 12 + CORE_URL: https://github.com/xfz347/Clash.Meta.git + CORE_REV: 98ca8a7bf1737001d5488baf93223c9878e532a7 + GVISOR_URL: https://github.com/likuai2010/gvisor-ohos.git + GVISOR_REV: 82104eaba946b7cc463b87648af9724d3f268537 + BOOTSTRAP_GO_VERSION: "1.24.13" + GO_OHOS_URL: https://github.com/moodyhunter/go-tls-mode-gd.git + GO_OHOS_REV: 0136c5054dec5322f49a4d5431a610b972c44b4f + GO_OHOS_DIR: ${{ github.workspace }}/.go-ohos + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # --- 1. Source ----------------------------------------------------------- + - name: Checkout (submodules deferred) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: false + persist-credentials: false + + - name: Fetch pinned flclash core + gVisor revisions + # The parent repo pins a `core` submodule commit that upstream force-pushed + # away. These revisions are the tested tips of the two `ohos` branches from + # the first successful cloud build; pinning them prevents silent API drift. + run: | + rm -rf "$CORE_DIR/core" "$CORE_DIR/gvisor-ohos" + + fetch_revision() { + local repository="$1" + local revision="$2" + local destination="$3" + mkdir -p "$destination" + git -C "$destination" init --quiet + git -C "$destination" remote add origin "$repository" + git -C "$destination" fetch --quiet --depth 1 origin "$revision" + git -C "$destination" checkout --quiet --detach FETCH_HEAD + test "$(git -C "$destination" rev-parse HEAD)" = "$revision" + } + + fetch_revision "$CORE_URL" "$CORE_REV" "$CORE_DIR/core" + fetch_revision "$GVISOR_URL" "$GVISOR_REV" "$CORE_DIR/gvisor-ohos" + test -f "$CORE_DIR/core/go.mod" + test -f "$CORE_DIR/gvisor-ohos/go.mod" + echo "core: $(git -C "$CORE_DIR/core" rev-parse HEAD)" + echo "gvisor: $(git -C "$CORE_DIR/gvisor-ohos" rev-parse HEAD)" + + - name: Add API compat shims to the cloned core + # The flclash glue code (hub.go / lib_linux.go) was written against the lost + # pinned core commit. The ohos branch has the same functionality under new + # names, so inject tiny same-package wrappers instead of forking the core: + # statistic.Manager.NowTraffic -> renamed to Current(onlyProxy) + # statistic.Manager.TotalTraffic -> renamed to Total(onlyProxy) + # iface.SetNetInterfaces -> unexported as setNetInterfaces + run: | + cat > "$CORE_DIR/core/component/iface/zz_flclash_compat.go" <<'EOF' + package iface + + import "net" + + // SetNetInterfaces re-exports setNetInterfaces for the flclash glue code. + func SetNetInterfaces(nets []net.Interface) { setNetInterfaces(nets) } + EOF + cat > "$CORE_DIR/core/tunnel/statistic/zz_flclash_compat.go" <<'EOF' + package statistic + + // NowTraffic / TotalTraffic keep the names the flclash glue code expects; + // the ohos core renamed them to Current / Total (same semantics). + func (m *Manager) NowTraffic(onlyProxy bool) (up, down int64) { return m.Current(onlyProxy) } + func (m *Manager) TotalTraffic(onlyProxy bool) (up, down int64) { return m.Total(onlyProxy) } + EOF + + # --- 2. Toolchains ------------------------------------------------------- + - name: Setup OpenHarmony NDK + id: ohos + uses: openharmony-rs/setup-ohos-sdk@b312caf8a43bb836aa24c08f65f97e1f09a89cad # v1.0.1 + with: + version: ${{ env.OHOS_SDK_VERSION }} + components: native + + - name: Setup bootstrap Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ env.BOOTSTRAP_GO_VERSION }} + cache: false + + - name: Restore OHOS Go toolchain (cache) + id: cache-go-ohos + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.GO_OHOS_DIR }} + key: gotoolchain-${{ runner.os }}-${{ runner.arch }}-${{ env.BOOTSTRAP_GO_VERSION }}-${{ env.GO_OHOS_REV }} + + - name: Fetch pinned patched Go toolchain + if: steps.cache-go-ohos.outputs.cache-hit != 'true' + run: | + # Go 1.26 with the tls-mode-gd patch already applied: adds -tlsmodegd + # (arm64 general-dynamic TLS) so the c-shared .so is dlopen-able on OHOS musl. + mkdir -p "$GO_OHOS_DIR" + git -C "$GO_OHOS_DIR" init --quiet + git -C "$GO_OHOS_DIR" remote add origin "$GO_OHOS_URL" + git -C "$GO_OHOS_DIR" fetch --quiet --depth 1 origin "$GO_OHOS_REV" + git -C "$GO_OHOS_DIR" checkout --quiet --detach FETCH_HEAD + test "$(git -C "$GO_OHOS_DIR" rev-parse HEAD)" = "$GO_OHOS_REV" + grep -q 'tlsmodegd' "$GO_OHOS_DIR/src/cmd/go/internal/work/build.go" + + - name: Build OHOS Go toolchain (make.bash) + if: steps.cache-go-ohos.outputs.cache-hit != 'true' + working-directory: ${{ env.GO_OHOS_DIR }}/src + env: + GOTOOLCHAIN: local + run: GOROOT_BOOTSTRAP="$(go env GOROOT)" ./make.bash + + - name: Verify OHOS Go toolchain + run: | + test -x "$GO_OHOS_DIR/bin/go" + test "$(git -C "$GO_OHOS_DIR" rev-parse HEAD)" = "$GO_OHOS_REV" + grep -q 'tlsmodegd' "$GO_OHOS_DIR/src/cmd/go/internal/work/build.go" + "$GO_OHOS_DIR/bin/go" version + + # --- 3. Build env -------------------------------------------------------- + - name: Configure CGO / cross-compile env + run: | + native='${{ steps.ohos.outputs.ohos_sdk_native }}' + llvm="$native/llvm" + sysroot="$native/sysroot" + { + echo "GO_OHOS_BIN=$GO_OHOS_DIR/bin/go" + echo "GOTOOLCHAIN=local" + echo "CC=$llvm/bin/clang" + echo "CXX=$llvm/bin/clang++" + echo "CGO_AR=$llvm/bin/llvm-ar" + echo "GOOS=linux" + echo "GOARCH=arm64" + echo "CGO_ENABLED=1" + echo "CGO_CFLAGS=--target=aarch64-linux-ohos --sysroot=$sysroot -D__MUSL__ -fPIC -Wno-error" + echo "CGO_LDFLAGS=--target=aarch64-linux-ohos --sysroot=$sysroot -fuse-ld=lld" + } >> "$GITHUB_ENV" + + # --- 3b. Reconcile modules ---------------------------------------------- + - name: Reconcile flclash go.mod with the ohos core + working-directory: ${{ env.CORE_DIR }} + env: + GOFLAGS: -tags=ohos,with_gvisor + run: | + # flclash's committed go.mod/go.sum pin the lost core commit and drag in + # NEWER metacubex deps than the ohos core expects (quic-go/sing-quic API + # drift). Regenerate a minimal go.mod so ./core's go.mod drives versions. + rm -f go.mod go.sum + "$GO_OHOS_BIN" mod init core + "$GO_OHOS_BIN" mod edit \ + -go=1.23 \ + -require=github.com/likuai2010/ohos-napi@v1.0.3 \ + -require=github.com/metacubex/mihomo@v1.17.1 \ + -require=github.com/samber/lo@v1.53.0 \ + -replace=github.com/metacubex/mihomo=./core \ + -replace=github.com/metacubex/gvisor=./gvisor-ohos + "$GO_OHOS_BIN" mod tidy + + # --- 4. Compile ---------------------------------------------------------- + - name: Build libflclash.so + working-directory: ${{ env.CORE_DIR }} + run: | + # GOOS=linux + c-shared + -tlsmodegd forces arm64 general-dynamic TLS so + # the .so is dlopen-able on OHOS musl. The -tlsmodegd flag comes from the + # patch applied to the toolchain above (moodyhunter/libHv2rayCore). + # -mod=mod: the flclash go.sum is pinned to the old (lost) core; the ohos + # core pulls newer deps, so let go add the missing go.sum entries / download. + "$GO_OHOS_BIN" build \ + -mod=mod \ + -tlsmodegd \ + -buildmode c-shared \ + -tags "ohos with_gvisor" \ + -trimpath -ldflags "-s -w" \ + -o libflclash.so ./ + + - name: Verify output + working-directory: ${{ env.CORE_DIR }} + run: | + test -s libflclash.so + ls -la libflclash.so + + file libflclash.so + elf_header="$(readelf -h libflclash.so)" + dynamic_section="$(readelf -d libflclash.so)" + program_headers="$(readelf -l libflclash.so)" + dynamic_symbols="$(readelf --dyn-syms --wide libflclash.so)" + + echo "$elf_header" + echo "$dynamic_section" + grep -F "Class: ELF64" <<<"$elf_header" >/dev/null + grep -F "Type: DYN (Shared object file)" <<<"$elf_header" >/dev/null + grep -F "Machine: AArch64" <<<"$elf_header" >/dev/null + + for library in libhilog_ndk.z.so libace_napi.z.so libc.so; do + grep -F "Shared library: [$library]" <<<"$dynamic_section" >/dev/null + done + if grep -F "(TEXTREL)" <<<"$dynamic_section" >/dev/null; then + echo "::error::libflclash.so contains text relocations" + exit 1 + fi + if grep -F " INTERP " <<<"$program_headers" >/dev/null; then + echo "::error::A shared library must not contain a program interpreter" + exit 1 + fi + grep -E "[[:space:]]InitializeModule$" <<<"$dynamic_symbols" >/dev/null + + sha256sum libflclash.so | tee libflclash.so.sha256 + checksum="$(cut -d ' ' -f 1 libflclash.so.sha256)" + { + echo "### OHOS core artifact" + echo + echo "- core: \`$CORE_REV\`" + echo "- gVisor: \`$GVISOR_REV\`" + echo "- patched Go: \`$GO_OHOS_REV\`" + echo "- SHA-256: \`$checksum\`" + echo "- size: $(stat -c '%s bytes' libflclash.so)" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Stage into libs/arm64-v8a + run: | + mkdir -p proxy_core/libs/arm64-v8a + cp -f "$CORE_DIR/libflclash.so" proxy_core/libs/arm64-v8a/libflclash.so + cp -f "$CORE_DIR/libflclash.so.sha256" proxy_core/libs/arm64-v8a/libflclash.so.sha256 + + # --- 5. Publish ---------------------------------------------------------- + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: libflclash-arm64-v8a + path: | + proxy_core/libs/arm64-v8a/libflclash.so + proxy_core/libs/arm64-v8a/libflclash.so.sha256 + if-no-files-found: error + retention-days: 14 + + package-hap: + name: Package, sign and verify HAP + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + container: + # CLT 6.0.2.642 image, pinned to its immutable registry digest. + image: ghcr.io/sanchuanhehe/harmony-next-pipeline-docker/harmonyos-ci-image@sha256:2836142b7b6ae8837ad0e36cacc650f00e8c88f55b4e9f34be66cc98a731c5da + env: + HAP_BUNDLE_NAME: org.xbgroup.clashboxLTS + HAP_COMPATIBLE_VERSION: "17" + steps: + - name: Checkout application + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: false + persist-credentials: false + + - name: Verify vendored UI components + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + test -s xb_components/Index.ets + test -s xb_components/oh-package.json5 + test "$(cat xb_components/.upstream-revision)" = \ + "55efa4d59785a94c2a9ff06010e1874c7fb30107" + + - name: Download compiled arm64 core + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: libflclash-arm64-v8a + path: ${{ runner.temp }}/libflclash-arm64-v8a + + - name: Verify and stage compiled core + working-directory: ${{ runner.temp }}/libflclash-arm64-v8a + run: | + test -s libflclash.so + test -s libflclash.so.sha256 + sha256sum --check libflclash.so.sha256 + mkdir -p "$GITHUB_WORKSPACE/proxy_core/libs/arm64-v8a" + cp libflclash.so "$GITHUB_WORKSPACE/proxy_core/libs/arm64-v8a/libflclash.so" + test -s "$GITHUB_WORKSPACE/proxy_core/libs/arm64-v8a/libflclash.so" + + - name: Configure tool environment + run: | + node_bin="$COMMANDLINE_TOOL_DIR/command-line-tools/tool/node/bin" + test -x "$node_bin/node" + echo "$node_bin" >> "$GITHUB_PATH" + export PATH="$node_bin:$PATH" + + node --version + java -version + ohpm --version + hvigorw --version + test -d "$OHOS_BASE_SDK_HOME/toolchains/lib" + + - name: Install OHPM dependencies + run: ohpm install --all + + - name: Assemble unsigned release HAP + run: | + hvigorw clean --no-daemon --no-parallel + hvigorw assembleHap \ + --mode module \ + -p product=default \ + -p buildMode=release \ + --no-daemon \ + --no-parallel \ + --stacktrace + + - name: Locate unsigned HAP + id: unsigned + shell: bash + run: | + mapfile -t haps < <( + find entry/build -type f -name '*-unsigned.hap' -print | + sort + ) + if [[ "${#haps[@]}" -ne 1 ]]; then + printf 'Expected exactly one unsigned HAP, found %s:\n' "${#haps[@]}" >&2 + printf ' %s\n' "${haps[@]:-}" >&2 + exit 1 + fi + test -s "${haps[0]}" + echo "hap=${haps[0]}" >> "$GITHUB_OUTPUT" + ls -lh "${haps[0]}" + + - name: Sign and verify HAP + id: sign + shell: bash + env: + HAP_SIGNING_P12_B64: ${{ secrets.HAP_SIGNING_P12_B64 }} + HAP_SIGNING_CERT_B64: ${{ secrets.HAP_SIGNING_CERT_B64 }} + HAP_SIGNING_PROFILE_B64: ${{ secrets.HAP_SIGNING_PROFILE_B64 }} + HAP_KEY_ALIAS: ${{ secrets.HAP_KEY_ALIAS }} + HAP_KEY_PASSWORD: ${{ secrets.HAP_KEY_PASSWORD }} + HAP_STORE_PASSWORD: ${{ secrets.HAP_STORE_PASSWORD }} + run: | + bash scripts/ci/sign-hap.sh \ + "${{ steps.unsigned.outputs.hap }}" \ + artifacts + + - name: Publish HAP summary + shell: bash + run: | + checksum="$(cut -d ' ' -f 1 artifacts/SHA256SUMS)" + { + echo "### Installable HAP" + echo + echo "- file: \`${{ steps.sign.outputs.hap_name }}\`" + echo "- signing: \`${{ steps.sign.outputs.signing_mode }}\`" + echo "- bundle: \`$HAP_BUNDLE_NAME\`" + echo "- ABI: \`arm64-v8a\`" + echo "- SHA-256: \`$checksum\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload signed HAP + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.sign.outputs.artifact_name }} + path: | + artifacts/*.hap + artifacts/SHA256SUMS + artifacts/build-metadata.txt + artifacts/INSTALLATION-NOTES.txt + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/release-hap.yml b/.github/workflows/release-hap.yml new file mode 100644 index 00000000..f6c54b26 --- /dev/null +++ b/.github/workflows/release-hap.yml @@ -0,0 +1,504 @@ +name: Release unsigned HAP + +# Builds a fresh OHOS native core at the pinned revisions (same toolchain and +# steps as the `build` job in build-core.yml) and packages the UNSIGNED release +# HAP, then publishes it as a GitHub Release: +# +# - push of a version tag (`1.2.3`, `1.7.4-lts-stable.1`, ...) +# -> stable release named after the tag +# - manual workflow_dispatch +# -> rolling `nightly` prerelease (deleted and recreated on every run) +# +# The signed pipeline lives in build-core.yml. An unsigned HAP cannot be +# installed on commercial HarmonyOS devices directly: it must be re-signed +# with a Huawei developer certificate in DevEco Studio or with hap-sign-tool +# (see docs/ci-hap-signing.md). + +on: + workflow_dispatch: {} + push: + tags: + # Matches ClashBox-style version tags (1.2.3, 1.7.4-lts-stable.1, ...). + # Deliberately excludes the `nightly` tag so the rolling prerelease that + # this workflow creates never retriggers itself. + - "*.*.*" + +permissions: + contents: read + +concurrency: + group: release-ohos-hap-${{ github.ref }} + cancel-in-progress: false + +env: + CORE_DIR: proxy_core/src/flclash + OHOS_SDK_VERSION: "5.0.0" # API 12 + CORE_URL: https://github.com/xfz347/Clash.Meta.git + CORE_REV: 98ca8a7bf1737001d5488baf93223c9878e532a7 + GVISOR_URL: https://github.com/likuai2010/gvisor-ohos.git + GVISOR_REV: 82104eaba946b7cc463b87648af9724d3f268537 + BOOTSTRAP_GO_VERSION: "1.24.13" + GO_OHOS_URL: https://github.com/moodyhunter/go-tls-mode-gd.git + GO_OHOS_REV: 0136c5054dec5322f49a4d5431a610b972c44b4f + GO_OHOS_DIR: ${{ github.workspace }}/.go-ohos + +jobs: + build: + # Mirrors the `build` job of build-core.yml step-for-step. When bumping + # CORE_REV / GVISOR_REV / GO_OHOS_REV / BOOTSTRAP_GO_VERSION or changing + # any toolchain step, update BOTH workflows. + name: Build OHOS native core + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + # --- 1. Source ----------------------------------------------------------- + - name: Checkout (submodules deferred) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: false + persist-credentials: false + + - name: Fetch pinned flclash core + gVisor revisions + # The parent repo pins a `core` submodule commit that upstream force-pushed + # away. These revisions are the tested tips of the two `ohos` branches from + # the first successful cloud build; pinning them prevents silent API drift. + run: | + rm -rf "$CORE_DIR/core" "$CORE_DIR/gvisor-ohos" + + fetch_revision() { + local repository="$1" + local revision="$2" + local destination="$3" + mkdir -p "$destination" + git -C "$destination" init --quiet + git -C "$destination" remote add origin "$repository" + git -C "$destination" fetch --quiet --depth 1 origin "$revision" + git -C "$destination" checkout --quiet --detach FETCH_HEAD + test "$(git -C "$destination" rev-parse HEAD)" = "$revision" + } + + fetch_revision "$CORE_URL" "$CORE_REV" "$CORE_DIR/core" + fetch_revision "$GVISOR_URL" "$GVISOR_REV" "$CORE_DIR/gvisor-ohos" + test -f "$CORE_DIR/core/go.mod" + test -f "$CORE_DIR/gvisor-ohos/go.mod" + echo "core: $(git -C "$CORE_DIR/core" rev-parse HEAD)" + echo "gvisor: $(git -C "$CORE_DIR/gvisor-ohos" rev-parse HEAD)" + + - name: Add API compat shims to the cloned core + # The flclash glue code (hub.go / lib_linux.go) was written against the lost + # pinned core commit. The ohos branch has the same functionality under new + # names, so inject tiny same-package wrappers instead of forking the core: + # statistic.Manager.NowTraffic -> renamed to Current(onlyProxy) + # statistic.Manager.TotalTraffic -> renamed to Total(onlyProxy) + # iface.SetNetInterfaces -> unexported as setNetInterfaces + run: | + cat > "$CORE_DIR/core/component/iface/zz_flclash_compat.go" <<'EOF' + package iface + + import "net" + + // SetNetInterfaces re-exports setNetInterfaces for the flclash glue code. + func SetNetInterfaces(nets []net.Interface) { setNetInterfaces(nets) } + EOF + cat > "$CORE_DIR/core/tunnel/statistic/zz_flclash_compat.go" <<'EOF' + package statistic + + // NowTraffic / TotalTraffic keep the names the flclash glue code expects; + // the ohos core renamed them to Current / Total (same semantics). + func (m *Manager) NowTraffic(onlyProxy bool) (up, down int64) { return m.Current(onlyProxy) } + func (m *Manager) TotalTraffic(onlyProxy bool) (up, down int64) { return m.Total(onlyProxy) } + EOF + + # --- 2. Toolchains ------------------------------------------------------- + - name: Setup OpenHarmony NDK + id: ohos + uses: openharmony-rs/setup-ohos-sdk@b312caf8a43bb836aa24c08f65f97e1f09a89cad # v1.0.1 + with: + version: ${{ env.OHOS_SDK_VERSION }} + components: native + + - name: Setup bootstrap Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ env.BOOTSTRAP_GO_VERSION }} + cache: false + + - name: Restore OHOS Go toolchain (cache) + id: cache-go-ohos + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.GO_OHOS_DIR }} + key: gotoolchain-${{ runner.os }}-${{ runner.arch }}-${{ env.BOOTSTRAP_GO_VERSION }}-${{ env.GO_OHOS_REV }} + + - name: Fetch pinned patched Go toolchain + if: steps.cache-go-ohos.outputs.cache-hit != 'true' + run: | + # Go 1.26 with the tls-mode-gd patch already applied: adds -tlsmodegd + # (arm64 general-dynamic TLS) so the c-shared .so is dlopen-able on OHOS musl. + mkdir -p "$GO_OHOS_DIR" + git -C "$GO_OHOS_DIR" init --quiet + git -C "$GO_OHOS_DIR" remote add origin "$GO_OHOS_URL" + git -C "$GO_OHOS_DIR" fetch --quiet --depth 1 origin "$GO_OHOS_REV" + git -C "$GO_OHOS_DIR" checkout --quiet --detach FETCH_HEAD + test "$(git -C "$GO_OHOS_DIR" rev-parse HEAD)" = "$GO_OHOS_REV" + grep -q 'tlsmodegd' "$GO_OHOS_DIR/src/cmd/go/internal/work/build.go" + + - name: Build OHOS Go toolchain (make.bash) + if: steps.cache-go-ohos.outputs.cache-hit != 'true' + working-directory: ${{ env.GO_OHOS_DIR }}/src + env: + GOTOOLCHAIN: local + run: GOROOT_BOOTSTRAP="$(go env GOROOT)" ./make.bash + + - name: Verify OHOS Go toolchain + run: | + test -x "$GO_OHOS_DIR/bin/go" + test "$(git -C "$GO_OHOS_DIR" rev-parse HEAD)" = "$GO_OHOS_REV" + grep -q 'tlsmodegd' "$GO_OHOS_DIR/src/cmd/go/internal/work/build.go" + "$GO_OHOS_DIR/bin/go" version + + # --- 3. Build env -------------------------------------------------------- + - name: Configure CGO / cross-compile env + run: | + native='${{ steps.ohos.outputs.ohos_sdk_native }}' + llvm="$native/llvm" + sysroot="$native/sysroot" + { + echo "GO_OHOS_BIN=$GO_OHOS_DIR/bin/go" + echo "GOTOOLCHAIN=local" + echo "CC=$llvm/bin/clang" + echo "CXX=$llvm/bin/clang++" + echo "CGO_AR=$llvm/bin/llvm-ar" + echo "GOOS=linux" + echo "GOARCH=arm64" + echo "CGO_ENABLED=1" + echo "CGO_CFLAGS=--target=aarch64-linux-ohos --sysroot=$sysroot -D__MUSL__ -fPIC -Wno-error" + echo "CGO_LDFLAGS=--target=aarch64-linux-ohos --sysroot=$sysroot -fuse-ld=lld" + } >> "$GITHUB_ENV" + + # --- 3b. Reconcile modules ---------------------------------------------- + - name: Reconcile flclash go.mod with the ohos core + working-directory: ${{ env.CORE_DIR }} + env: + GOFLAGS: -tags=ohos,with_gvisor + run: | + # flclash's committed go.mod/go.sum pin the lost core commit and drag in + # NEWER metacubex deps than the ohos core expects (quic-go/sing-quic API + # drift). Regenerate a minimal go.mod so ./core's go.mod drives versions. + rm -f go.mod go.sum + "$GO_OHOS_BIN" mod init core + "$GO_OHOS_BIN" mod edit \ + -go=1.23 \ + -require=github.com/likuai2010/ohos-napi@v1.0.3 \ + -require=github.com/metacubex/mihomo@v1.17.1 \ + -require=github.com/samber/lo@v1.53.0 \ + -replace=github.com/metacubex/mihomo=./core \ + -replace=github.com/metacubex/gvisor=./gvisor-ohos + "$GO_OHOS_BIN" mod tidy + + # --- 4. Compile ---------------------------------------------------------- + - name: Build libflclash.so + working-directory: ${{ env.CORE_DIR }} + run: | + # GOOS=linux + c-shared + -tlsmodegd forces arm64 general-dynamic TLS so + # the .so is dlopen-able on OHOS musl. The -tlsmodegd flag comes from the + # patch applied to the toolchain above (moodyhunter/libHv2rayCore). + # -mod=mod: the flclash go.sum is pinned to the old (lost) core; the ohos + # core pulls newer deps, so let go add the missing go.sum entries / download. + "$GO_OHOS_BIN" build \ + -mod=mod \ + -tlsmodegd \ + -buildmode c-shared \ + -tags "ohos with_gvisor" \ + -trimpath -ldflags "-s -w" \ + -o libflclash.so ./ + + - name: Verify output + working-directory: ${{ env.CORE_DIR }} + run: | + test -s libflclash.so + ls -la libflclash.so + + file libflclash.so + elf_header="$(readelf -h libflclash.so)" + dynamic_section="$(readelf -d libflclash.so)" + program_headers="$(readelf -l libflclash.so)" + dynamic_symbols="$(readelf --dyn-syms --wide libflclash.so)" + + echo "$elf_header" + echo "$dynamic_section" + grep -F "Class: ELF64" <<<"$elf_header" >/dev/null + grep -F "Type: DYN (Shared object file)" <<<"$elf_header" >/dev/null + grep -F "Machine: AArch64" <<<"$elf_header" >/dev/null + + for library in libhilog_ndk.z.so libace_napi.z.so libc.so; do + grep -F "Shared library: [$library]" <<<"$dynamic_section" >/dev/null + done + if grep -F "(TEXTREL)" <<<"$dynamic_section" >/dev/null; then + echo "::error::libflclash.so contains text relocations" + exit 1 + fi + if grep -F " INTERP " <<<"$program_headers" >/dev/null; then + echo "::error::A shared library must not contain a program interpreter" + exit 1 + fi + grep -E "[[:space:]]InitializeModule$" <<<"$dynamic_symbols" >/dev/null + + sha256sum libflclash.so | tee libflclash.so.sha256 + checksum="$(cut -d ' ' -f 1 libflclash.so.sha256)" + { + echo "### OHOS core artifact" + echo + echo "- core: \`$CORE_REV\`" + echo "- gVisor: \`$GVISOR_REV\`" + echo "- patched Go: \`$GO_OHOS_REV\`" + echo "- SHA-256: \`$checksum\`" + echo "- size: $(stat -c '%s bytes' libflclash.so)" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Stage into libs/arm64-v8a + run: | + mkdir -p proxy_core/libs/arm64-v8a + cp -f "$CORE_DIR/libflclash.so" proxy_core/libs/arm64-v8a/libflclash.so + cp -f "$CORE_DIR/libflclash.so.sha256" proxy_core/libs/arm64-v8a/libflclash.so.sha256 + + # --- 5. Publish ---------------------------------------------------------- + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: libflclash-arm64-v8a + path: | + proxy_core/libs/arm64-v8a/libflclash.so + proxy_core/libs/arm64-v8a/libflclash.so.sha256 + if-no-files-found: error + retention-days: 14 + + package: + name: Package unsigned release HAP + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + container: + # CLT 6.0.2.642 image, pinned to its immutable registry digest. + image: ghcr.io/sanchuanhehe/harmony-next-pipeline-docker/harmonyos-ci-image@sha256:2836142b7b6ae8837ad0e36cacc650f00e8c88f55b4e9f34be66cc98a731c5da + outputs: + version: ${{ steps.meta.outputs.version }} + hap_filename: ${{ steps.meta.outputs.hap_filename }} + env: + HAP_BUNDLE_NAME: org.xbgroup.clashboxLTS + steps: + - name: Checkout application + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: false + persist-credentials: false + + - name: Verify vendored UI components + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + test -s xb_components/Index.ets + test -s xb_components/oh-package.json5 + test "$(cat xb_components/.upstream-revision)" = \ + "55efa4d59785a94c2a9ff06010e1874c7fb30107" + + - name: Download compiled arm64 core + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: libflclash-arm64-v8a + path: ${{ runner.temp }}/libflclash-arm64-v8a + + - name: Verify and stage compiled core + working-directory: ${{ runner.temp }}/libflclash-arm64-v8a + run: | + test -s libflclash.so + test -s libflclash.so.sha256 + sha256sum --check libflclash.so.sha256 + mkdir -p "$GITHUB_WORKSPACE/proxy_core/libs/arm64-v8a" + cp libflclash.so "$GITHUB_WORKSPACE/proxy_core/libs/arm64-v8a/libflclash.so" + test -s "$GITHUB_WORKSPACE/proxy_core/libs/arm64-v8a/libflclash.so" + + - name: Configure tool environment + run: | + node_bin="$COMMANDLINE_TOOL_DIR/command-line-tools/tool/node/bin" + test -x "$node_bin/node" + echo "$node_bin" >> "$GITHUB_PATH" + export PATH="$node_bin:$PATH" + + node --version + java -version + ohpm --version + hvigorw --version + test -d "$OHOS_BASE_SDK_HOME/toolchains/lib" + + - name: Install OHPM dependencies + run: | + ohpm config set registry https://ohpm.openharmony.cn/ohpm/ + ohpm install --all + + - name: Assemble unsigned release HAP + run: | + hvigorw clean --no-daemon --no-parallel + hvigorw assembleHap \ + --mode module \ + -p product=default \ + -p buildMode=release \ + --no-daemon \ + --no-parallel \ + --stacktrace + + - name: Locate unsigned HAP + id: unsigned + shell: bash + run: | + mapfile -t haps < <( + find entry/build -type f -name '*-unsigned.hap' -print | + sort + ) + if [[ "${#haps[@]}" -ne 1 ]]; then + printf 'Expected exactly one unsigned HAP, found %s:\n' "${#haps[@]}" >&2 + printf ' %s\n' "${haps[@]:-}" >&2 + exit 1 + fi + test -s "${haps[0]}" + echo "hap=${haps[0]}" >> "$GITHUB_OUTPUT" + ls -lh "${haps[0]}" + + - name: Stage release assets + id: meta + shell: bash + run: | + set -euo pipefail + unsigned_hap="${{ steps.unsigned.outputs.hap }}" + test -s "$unsigned_hap" + + version="$(sed -n 's/.*"versionName"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' AppScope/app.json5 | head -n 1)" + test -n "$version" + sha7="${GITHUB_SHA:0:7}" + hap_filename="ClashBox-${version}-${sha7}-unsigned.hap" + + mkdir -p artifacts + cp -f "$unsigned_hap" "artifacts/$hap_filename" + ( + cd artifacts + sha256sum "$hap_filename" | tee SHA256SUMS + { + echo "commit=$GITHUB_SHA" + echo "bundle=$HAP_BUNDLE_NAME" + echo "version=$version" + echo "signing=none" + } > build-metadata.txt + printf '%s\n' \ + 'This HAP is UNSIGNED and cannot be installed on commercial HarmonyOS devices' \ + 'as-is. Re-sign it with a Huawei developer certificate in DevEco Studio or with' \ + 'hap-sign-tool before installing (see docs/ci-hap-signing.md). Signed builds are' \ + 'available from the build-core workflow.' \ + '' \ + '未签名 HAP:无法直接安装到商用 HarmonyOS 设备。安装前请使用华为开发者证书' \ + '在 DevEco Studio 或 hap-sign-tool 中重新签名(参见 docs/ci-hap-signing.md)。' \ + > INSTALLATION-NOTES.txt + ) + + checksum="$(cut -d ' ' -f 1 artifacts/SHA256SUMS)" + { + echo "### Unsigned release HAP" + echo + echo "- file: \`$hap_filename\`" + echo "- bundle: \`$HAP_BUNDLE_NAME\`" + echo "- version: \`$version\`" + echo "- signing: \`none\`" + echo "- SHA-256: \`$checksum\`" + } >> "$GITHUB_STEP_SUMMARY" + + { + echo "version=$version" + echo "hap_filename=$hap_filename" + } >> "$GITHUB_OUTPUT" + + - name: Upload unsigned HAP + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clashbox-unsigned-hap + path: | + artifacts/*.hap + artifacts/SHA256SUMS + artifacts/build-metadata.txt + artifacts/INSTALLATION-NOTES.txt + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish GitHub release + needs: package + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Download unsigned HAP bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: clashbox-unsigned-hap + path: ${{ runner.temp }}/release-assets + + - name: Publish GitHub release + shell: bash + run: | + set -euo pipefail + cd "$RUNNER_TEMP/release-assets" + + hap_file="${{ needs.package.outputs.hap_filename }}" + version="${{ needs.package.outputs.version }}" + for asset in "$hap_file" SHA256SUMS build-metadata.txt INSTALLATION-NOTES.txt; do + test -s "$asset" + done + + sha7="${GITHUB_SHA:0:7}" + checksum="$(cut -d ' ' -f 1 SHA256SUMS)" + { + echo "ClashBox **$version**(未签名 HAP,自动构建)" + echo + echo "- file: \`$hap_file\`" + echo "- bundle: \`org.xbgroup.clashboxLTS\`" + echo "- commit: \`$sha7\` (\`$GITHUB_SHA\`)" + echo "- SHA-256: \`$checksum\`" + echo + echo "> ⚠️ 未签名 HAP 无法直接安装到商用 HarmonyOS 设备。" + echo "> 安装前请用华为开发者证书在 DevEco Studio 或 hap-sign-tool 中重新签名," + echo "> 参见 docs/ci-hap-signing.md。带签名的构建产物见 build-core 工作流。" + } > notes.md + + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + tag="$GITHUB_REF_NAME" + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $tag already exists; uploading assets to it." + gh release upload "$tag" \ + "$hap_file" SHA256SUMS build-metadata.txt INSTALLATION-NOTES.txt \ + --clobber \ + --repo "$GITHUB_REPOSITORY" + else + # The tag was just pushed and already exists; no --target needed, + # which also keeps annotated tags working (GITHUB_SHA is the tag + # object, not a commit, for annotated tags). + gh release create "$tag" \ + "$hap_file" SHA256SUMS build-metadata.txt INSTALLATION-NOTES.txt \ + --repo "$GITHUB_REPOSITORY" \ + --title "ClashBox $tag(未签名 HAP)" \ + --notes-file notes.md + fi + else + # Rolling nightly prerelease for manual dispatches: the `nightly` + # tag has no dots, so it does not match the `*.*.*` tag filter and + # cannot retrigger this workflow. + gh release delete nightly --yes --cleanup-tag --repo "$GITHUB_REPOSITORY" || true + gh release create nightly \ + "$hap_file" SHA256SUMS build-metadata.txt INSTALLATION-NOTES.txt \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "ClashBox nightly $version(未签名 HAP)" \ + --prerelease \ + --notes-file notes.md + fi diff --git a/.gitignore b/.gitignore index b7e3e614..b2fbe26b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ proxy_core/src/flclash/libflclash.so /key /.* +!/.github run.sh internalTesting profiler \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 9ae0d1ca..f7bd2d84 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,3 @@ [submodule "proxy_core/src/flclash/gvisor-ohos"] path = proxy_core/src/flclash/gvisor-ohos url = https://github.com/likuai2010/gvisor-ohos.git -[submodule "xb_components"] - path = xb_components - url = https://gitee.com/xiaobai-studio/xb_components.git diff --git a/AppScope/app.json5 b/AppScope/app.json5 index 02c377e0..ad050a83 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "org.xbgroup.clashboxLTS", "vendor": "example", - "versionCode": 1007047, - "versionName": "1.7.4", + "versionCode": 1007048, + "versionName": "1.7.4-lts-stable.1", "icon": "$media:layered_image", "label": "$string:app_name" } diff --git a/INVESTIGATION-2026-08-11-synchronized-fin.md b/INVESTIGATION-2026-08-11-synchronized-fin.md new file mode 100644 index 00000000..d926b829 --- /dev/null +++ b/INVESTIGATION-2026-08-11-synchronized-fin.md @@ -0,0 +1,362 @@ +# ClashBox / Mihomo synchronized-FIN investigation — consolidated report + +Date: 2026-08-11. All timestamps UTC. Device: HarmonyOS PC (HongMeng Kernel 1.12.0, +aarch64), unprivileged shell uid 20020228. + +Original symptom (HarmonyOS Codex port): + + stream disconnected before completion: + IO error: peer closed connection without sending TLS close_notify + +## Headline conclusion + +On a precise ~180.5-second wall-clock grid, the installed ClashBox (unpublished +V2 line) issues a one-shot RPC from its UI process to its embedded Mihomo core +over the private Unix socket `clash_go.sock`, which closes **every** connection +tracker in `statistic.DefaultManager`. Mihomo's bidirectional relay then closes +each application-facing connection with an orderly FIN (TUN-side via gVisor, +localhost-side via the mixed listener). The application sees `read()==0`, +`CLOSE_WAIT`, `SO_ERROR==0`, and — because Mihomo relays opaque TLS bytes and +never injects `close_notify` — rustls reports `UnexpectedEof`. + +The wipe is **conditional on ClashBox running**. It is not caused by the +HarmonyOS VPN/TUN layer, Fake-IP, the physical network, the proxy node, the +remote endpoint, rustls, Tokio/mio, tungstenite, or the OHOS userspace. + +The periodic caller exists only in the unpublished V2 code. Public ClashBox +source (LTS) contains the destructive RPC handler but **no automatic caller**. + +--- + +## 1. Evidence timeline + +### Morning epochs (prior session, ClashBox active) + +06:18:28.145 · 06:30:30.702 · 06:42:32.968 · 07:54:44.797 · 08:00:45.970 · +08:18:48.729 · 08:36:51.532 · 08:54:54.370 (+ tracker replacement bracketed +08:57:54.810–.992). + +Least-squares fit (recomputed this session): **period 180.5004 s, every residual +within ±0.5 s over 52 cycles** — a software timer (e.g. ArkTS +`setInterval(180000)` with consistent per-cycle callback/scheduling drift), not +network jitter. + +### This session's epochs (ClashBox active) + +| Epoch | Cycle | What died | +| --- | --- | --- | +| 11:40:22.43–.47 | ~107 | 3 probe legs (TUN, ClashBox mixed :7890, standalone mihomo :17890), FIN within 15 ms | +| 11:46:22.41 | 109 | in-flight direct canary request (no long-lived flows existed) | +| 11:49:24.579–.581 | 110 | 3 probe legs, FIN within 2 ms | +| 11:52:25.411–.414 | 111 | 2 probe legs, 16.8 s after connecting (killed at first epoch after start) | + +### Decisive A/B (same device, same physical network, same core version string) + +| Window | ClashBox | Grid cycles | Wipes | +| --- | --- | --- | --- | +| 10:51–11:25 | OFF | 11 full cycles | **0** (2 tracker IDs stable, no EOF) | +| from 11:25:18 | ON | every observed cycle | **every epoch wipes all flows** | + +Standalone setup: upstream mihomo v1.19.27 built on-device (harmonybrew +go1.26.5, CGO off; version string shows 1.10.0 only because ldflags unset — +`git describe` = v1.19.27), running the user's `Ultimate.yaml` with ports moved +(mixed 17890, socks 17891, controller 127.0.0.1:19090, DNS 127.0.0.1:15353). +Probes: `hmos_wss_probe` (codex-websocket-client example, branch ohos-build of +jerry-271828/codex), `--proxy http://127.0.0.1:17890` vs direct, +`wss://ws.postman-echo.com/raw`, 3 s pings, full transport diagnostics. + +### The 11:25:18 anomaly — fully explained (NOT environmental) + +All three standalone legs (including the genuinely direct one bound to +192.168.3.20) died simultaneously at 11:25:18.54, the direct leg with +ECONNRESET. `/proc` mtimes: ClashBox UI process created **11:25:12.873**, VPN +extension process **11:25:16.281**. Flows died when the VPN finished coming up +and the default network moved to `vpn-tun` — the normal "VPN establishment +invalidates existing sockets" behavior. Grid proximity (−0.65 s) was +coincidence. + +## 2. What happens at every epoch (all observed together) + +- Controller tracker table goes `connections: null` 10–65 ms **before** app + FINs; entirely new tracker IDs appear immediately after. +- Every app socket: `POLLIN|POLLOUT|POLLRDHUP`, `MSG_PEEK==0`, `SO_ERROR==0`, + `TCP_INFO.state==CLOSE_WAIT`, `read()==0` → rustls `UnexpectedEof`. +- 5-second short-request canary succeeds **through** epochs — only pre-existing + flows die; an in-flight connect at the epoch is killed. +- Quiet-period captures (morning): exactly one new `clash_go.sock` RPC + connection at the epoch, the only RPC in a 118 s window (observed twice). + (With the UI active the background RPC rate is ~2/s, so this signature is + only detectable in quiet periods.) +- **No** HarmonyOS network-layer event at epochs: no available/lost/capability + callbacks, no route/address changes, `vpn-tun` counters continue, hilog shows + only steady background noise. +- Both ClashBox processes stay alive; UI/VPN appear healthy. +- Ordering proof that "table empties before app FIN" is *propagation*, not + necessarily RPC causality: at 11:40:22 my standalone mihomo's table emptied + at .432 purely because ClashBox killed its TUN-captured outbound; its relays + then FINed my probes at .463–.470. + +## 3. Mechanism, source-verified (upstream mihomo v1.19.27) + +- `tunnel/statistic/tracker.go:110-113` — `tcpTracker.Close()` removes itself + from the manager and closes the wrapped **outbound** connection. +- `tunnel/tunnel.go:613-622` — that tracker is the outbound half of the relay; + the TUN/mixed-listener connection is the inbound half. +- `common/net/sing.go:69-92` — `Relay` closes both connections when either copy + direction ends → orderly FIN toward the application. + +ClashBox wrapper paths (public master `1fdc47eb`): + +- RPC dispatch: `proxy_core/src/flclash/ipc.go` — method 11 `ClearConnections` + (`:146-148`), method 12 `Load` (`:149-153`); JSON framing, one request per + connection, `{"method":N,"params":[...]}`. +- Global close: `handleCloseConnectionsUnLock`, + `proxy_core/src/flclash/hub.go:247-269` — iterates every tracker, `Close()`. +- Non-patch apply: `proxy_core/src/flclash/common.go:349-366` — calls the same + close before `hub.ApplyConfig` even when config bytes are unchanged. +- Public callers: `ClearConnections` only from the manual clean button + (`entry/src/main/ets/components/More/Connect.ets:147-157` via + `ClashViewModel.ets:313-315`). Non-patch load from init/recovery + (`ClashViewModel.ets:421`, `:537`). **No periodic caller in public source.** + +## 4. Trigger: method 11 vs method 12 + +Favors **method 11 (`ClearConnections`)**: + +- An idle HTTP/1.1 keep-alive connection to the external controller + (`127.0.0.1:9090`) survived an epoch by 67+ s. A public-style non-patch Load + would call `hub.ApplyConfig` → `route.ReCreateServer` (`hub/hub.go:43-60`) → + `httpServer.Close()` (`hub/route/server.go:160-164`), and pinned + `metacubex/http` v0.1.6 `Server.Close` closes **all** active connections + including idle ones (verified `server.go:3082-3101`). Caveat: the unpublished + V2 core could have changed apply behavior. +- No core-internal periodic wipe exists in the OHOS core or upstream (only + per-provider close on provider init and the controller DELETE API; the 1 s + manager ticker is traffic sampling). Empirically the standalone core ran 11 + clean cycles. + +## 5. Exonerated (each with direct evidence) + +- rustls / Tokio / mio / tungstenite / codex-websocket-client — DockerHarmony + native OHOS probes survived 1800 s twice (GH runs 31474572903, 31477521209, + repo jerry-271828/codex branch ohos-build). +- OHOS userspace + Rust stack generally — same runs. +- echo.websocket.org — kills connections at ~600 s **connection age** itself + (two staggered connections died 34 s apart at 605.5 s age); excluded from + probes as a confound. +- Physical network / Wi-Fi / ISP — 33-min clean standalone window on the same + network, and no-flow-deaths while ClashBox was off. +- HarmonyOS VPN/TUN/netstack — no network callbacks at epochs; TUN counters + continuous; routes/addresses stable; processes stable. +- Fake-IP — localhost CONNECT bypasses it and still fails (Phase 1 A/B). +- Proxy node / endpoint — two nodes reproduce; postman direct also dies when + ClashBox is on. +- The hybrid musl/OHOS codex target — native `aarch64-unknown-linux-ohos` + probes both reproduce (with ClashBox) and survive (without ClashBox). + +## 6. Installed-version vs public-source parity + +- Installed: `org.xbgroup.clashbox`, most likely the unpublished V2 line + (2.0.x, AppGallery-pushed); embedded controller reports Mihomo 1.19.27. + Package metadata inaccessible to this shell (`bm`/`aa` denied). +- Public: bundle `org.xbgroup.clashboxLTS`, latest release 1.7.4 (`f78de056`); + audited master `1fdc47eb` (2026-07-05). No parity established; all statements + about the installed binary are empirical. +- Public ClashBox LTS CI core is built from `xfz347/Clash.Meta` ohos branch + (reports 1.10.0-based) — also not the installed V2 core. + +## 7. Narrowest responsible component + +**The ClashBox-V2 ArkTS wrapper's periodic (~180.5 s) RPC into the embedded +core over `clash_go.sock`, which wipes `statistic.DefaultManager` — most +probably `ClearConnections` (method 11).** The destructive core path itself +(`handleCloseConnectionsUnLock`) is shared with public source; the periodic +caller is V2-only. + +## 8. Instrumented build (prepared; needs one GUI step) + +Branch `diag/close-trigger-instrumented` on `jerry-271828/ClashBox`, commit +`ae41bbd5` (base `ci/ohos-core-build` + minimal patch, 49 ins / 10 del). +CI run 31484200385 succeeded. The patch logs, via the mihomo log stream +(`[NETDIAG]` prefix, RFC3339-nano + monotonic): + +- `ipc_request_received method=N` for every core RPC; +- `apply_config_begin is_patch=...`; +- `close_all_connections_begin/completed reason=rpc_clear_connections | + apply_config_non_patch` with before/after tracker counts. + +One wipe epoch with this build settles the method number. Note: the HAP is +signed with the OpenHarmony **test key** (no signing secrets configured) — +retail HarmonyOS may refuse it; if so, configure the six `HAP_SIGNING_*` +secrets with Huawei developer material and re-run CI. Bundle is +`org.xbgroup.clashboxLTS` (coexists with installed V2; only one VPN at a time). +If the wipe does **not** reproduce on LTS, that alone proves V2-specificity. + +## 9. Fix direction and validation + +The fix belongs to the unpublished V2 wrapper: stop issuing the periodic +connection-clearing RPC (or, if method 12, use patch-load / skip tracker close +when the config is unchanged). Nothing to fix in public LTS, Mihomo, or Codex. + +- Report to `xiaobaigroup/ClashBox`; complements open issue #158 (long-run VPN + stability; its stall symptoms are a different failure mode of the same + wrapper layer). +- User-side check: toggle any periodic maintenance/cleanup-style V2 setting; + if the 180.5 s grid disappears, the caller is identified functionally. +- Validation after fix: four-flow probe set (TUN + mixed-port × 2 endpoints, + 3 s pings) survives ≥3 consecutive predicted epochs; controller tracker IDs + unchanged across epochs; pongs continuous. Keep the Codex transport + diagnostics patch until this passes. + +## 10. Practical side effect observed + +With ClashBox active, any download/stream lasting longer than the current +~180.5 s grid remainder is killed (GitHub artifact downloads repeatedly died +mid-stream with "unexpected EOF" — the bug truncating its own evidence). + +## 11. Environment/build gotchas recorded (device) + +- cargo target dir must be ext4 (`/data/storage/el2/base/cache/...`); sharefs + gives ETXTBSY on build scripts. +- aws-lc-sys 0.39 on `aarch64-unknown-linux-ohos`: set + `OHOS_SDK_NATIVE=/storage/Users/currentUser/.harmonybrew/opt/ohos-sdk/native` + and delete stale `target/release/build/aws-lc-sys-*` (its rerun-if-env list + omits OHOS_SDK_NATIVE), else cmake skips asm and the link fails on + `aws_lc_0_39_0_*_neon` symbols. +- go build default output runs as-is; do NOT re-sign with binary-sign-tool; + `-buildmode=pie` segfaults. +- `GOPROXY=https://goproxy.cn,direct` (default proxy.golang.org unreachable). +- git clone of github.com is flaky (SSL EOF); `gh api` tarball/artifacts work. + +## 11b. Second-session addendum (2026-08-11 PM UTC) + +### RPC method: 11 (ClearConnections), two independent runtime discriminators + +Both rely only on the V2 core behaving like public v1.19.27 in two unremarkable +code paths: + +1. **Controller keep-alive survival** (prior session): an idle HTTP/1.1 + keep-alive connection to `127.0.0.1:9090` survived an epoch by 67+ s. + `hub.ApplyConfig` → `route.ReCreateServer` → `httpServer.Close()`, and + pinned `metacubex/http` v0.1.6 `Server.Close` closes **all** active + connections (`server.go:3082-3101`). A non-patch Load would have killed it. +2. **No controller re-listen log at the epoch**: mihomo logs + `RESTful API listening at: ...` at INFO on every `ReCreateServer` + (`hub/route/server.go:174`). The captured core debug/info stream covering + the 08:54:54.370 epoch (2778 lines, 08:52:55–08:56:00, 180 info lines) + contains **zero** such lines → no `ApplyConfig` → no non-patch Load. + +So the periodic RPC is `ClearConnections` (method 11), whose only public caller +is the manual UI clean button — i.e. **the periodic caller is V2-specific +code**. Note `ReCreateMixed/Socks/...` return early when the address is +unchanged (`listener/listener.go:119-125`), so inbound listeners would NOT +change under a Load — the controller is the discriminating side effect. + +### Grid phase anchors to the ClashBox instance start + +- ClashBox was restarted at 12:31:55 (UI) / 12:32:46 (VPN) UTC (`/proc` mtimes). + The next observed wipe was 12:50:52.65 — 1086.3 s ≈ 6 × 180.5004 s after + ~12:32:49.6 (VPN process + ~3 s core init). The periodic task's phase + therefore starts near core start, not at a global wall-clock constant. +- Under the previous instance (started 11:25:12/16), wipes were observed at + every cycle that had live flows (cycles 5, 7, 8, 9). +- Under the new instance, the wipe at cycle 6 (12:50:52) occurred, then cycles + 7 (12:53:53) and 8 (12:56:53) did **not** wipe live probes — the caller's + activation is conditional on some state that changed around 12:51 (candidate: + UI/foreground state or a feature toggle; UI-lifecycle hilog capture in + progress to correlate). + +### No-install observation limits (documented dead ends) + +- `/proc/net/tcp(6)` is permission-denied in this sandbox (listener-inode watch + impossible); `/proc/net/unix` remains readable. +- `clash_go.sock` lives in the ClashBox mount namespace + (`/data/storage/el2/base/haps/entry/files/` is per-app); direct RPC + connection/sniffing from this shell is impossible. + +### The periodic caller only runs while the ClashBox UI process executes + +- Instance B (started 12:32): the last grid-signature wipe was 12:50:52.65 + (synchronized FIN, 40 ms spread). Since then, through ≥4 predicted grid + cycles, auto-restarting probes on BOTH paths (TUN fake-ip + :7890) survived — + verified still ClashBox-mediated (172.19.0.1 / 198.18.0.69). +- During the silent window the UI process (pid 30723) shows **zero CPU growth** + (`/proc` utime/stime flat across 7+ minutes) — its JS timers are not running + (backgrounded without an active keepalive). +- During the morning grid epochs, the UI process was executing (constant + AceStateMgmt render noise in hilog at 08:39–08:40, and the 11:25 instance's + wipe window 11:40–11:52 covers the user's active session). +- Therefore the caller is a UI-process ArkTS timer gated by process execution + (foreground or background-keepalive such as 长时任务/模拟画中画). This makes + the keepalive/PiP/background-run toggles the top bisection candidates and + explains why the bug appears "always on" for normal users (they run ClashBox + with background keepalive enabled) yet vanishes when the UI process is + suspended. +- A separate 13:06:38–43 event killed both long-lived legs with TCP **RST**, + staggered ~5 s, and also reset a brand-new connection — different signature + (not synchronized FIN), classified as an upstream/node/path blip, not the + grid bug. + +### Live A/B on 2026-08-11 PM (user present, no setting changes by us) + +Wipe epochs observed by the auto-restarting probe loops (both legs die within +ms, FIN/UnexpectedEof class = ClearConnections signature): + + 12:50:52.65 (6 x 180.5004 s after the 12:32:49.6 instance-start anchor) + 13:22:12.99 (transition-period wake) + 13:34:02.53 (transition-period wake) + 13:35:57.60 then 13:38:57.93, 13:41:58.10, 13:44:58.64, 13:47:58.87, + 13:50:59.60, 13:54:00.27 (steady ~180.3 s grid, UI executing) + +UI-process (pid 30723) CPU via /proc utime/stime: + + 13:07:23 - 13:14:04 utime/stime frozen (0 jiffies in >7 min) -> probes + crossed 4 predicted epochs with NO wipe + ~13:35 onward ~5% CPU sustained (1 s text render loop + ~3 RPC/s + LocalSocket churn visible in hilog) -> grid fires + every ~180.3 s + +Conclusion: the caller is an app-level ArkTS ~180 s timer in the V2 **UI +process** that runs iff the UI process executes JS (foreground window open — +on HarmonyOS PC an unfocused open window still executes — or a background +keepalive: 长时任务 / 模拟画中画 / 模拟音频 / 模拟定位 held). In the morning +the UI was executing under a keepalive (light RPC rate, no main-page polling); +in the afternoon it was foreground. Both states wipe. Suspended UI → no wipe +and the VPN extension keeps relaying normally (probes ponged through the whole +silent window). + +### V2 settings surface (from the May 2026 V2 settings export) + +`/storage/Users/currentUser/Download/org.xbgroup.clashbox/ClashBoxConfig_2026-05-15_07-39-57.json` +(oldVersionCode=2000021) names the V2 feature toggles: + + uiSettings: EnableBackgrounder, backgroundKeepTask (长时任务), + backgroundPiPModel (模拟画中画), BackgroundAudioService (模拟音频), + backgroundLocateModel (模拟定位), backgroundDownModel (模拟下载, LTS), + EnabledNotice / EnabledPermanentNotice / EnabledStatusNotice / + EnabledCoexistNotice (常驻/状态通知) + appSettings: enableConnect (连接管理页), enableRequest (请求记录页), + autoStart, autoCheckUpdate, accessControl ... + configList: 6 profiles; the then-active one is a LOCAL file (file://), + others are URL subscriptions + +All background keepalives were False in that May export; current on-device +state is not directly readable (sandbox). No 180000/3-minute constant exists +anywhere in public LTS source (searched); LTS periodic tasks are 0.9 s / 1 s / +1.5 s / 9 s (page-scoped) and 60 s (profile auto-update) — the ~180 s timer is +V2-added. + +## 12. Evidence locations + +- This session: `/storage/Users/currentUser/tmp/mihomo-standalone-k3X9q/` + (`SESSION-NOTES.md`, `evidence-20260811T105128Z/`, `evidence2-…/`, + `run-control.sh`, `run-extended.sh`, `monitor-standalone.cjs`, + `config-standalone.yaml`, `mihomo-upstream`, `mihomo-ohos`, probe binary in + `/data/storage/el2/base/cache/codex-target/release/examples/hmos_wss_probe`). +- Prior session: `/storage/Users/currentUser/tmp/clashbox-ab-20260811-gHQGwT/` + (`RESULTS.md`, `CLASHBOX_SOURCE_AUDIT.md`, both diagnostic patches, all raw + evidence dirs). +- Instrumented build checkout: `/storage/Users/currentUser/tmp/hap-diag-build/` + (artifact download still retrying in background at report time). +- Public source trees: `…/tmp/ClashBox-public-4pekUE`, + `…/tmp/mihomo-v1.19.27-BcBuZP`, `…/tmp/clash-meta-ohos-uGsj44`. diff --git a/LTS-BUILD-VALIDATION.md b/LTS-BUILD-VALIDATION.md new file mode 100644 index 00000000..b1d7ad39 --- /dev/null +++ b/LTS-BUILD-VALIDATION.md @@ -0,0 +1,92 @@ +# ClashBox LTS stable build — install & validation guide + +## The build + +- Branch: `fix/lts-stable-long-connections` @ `7eff34ad` (jerry-271828/ClashBox) +- Base: public LTS `master` @ `1fdc47eb` + reproducible CI; **zero app-logic + changes** (audit: `docs/lts-connection-lifecycle-audit.md` — no automatic + ClearConnections caller exists in LTS; nothing to patch) +- Artifact: `ClashBox-7eff34adbd99-openharmony-test-signed.hap` + sha256 `57fba87e4d4f54cbfafd8b3557585cada4bce4102ade96968a36d83b0b5695c0` + local copy: `/storage/Users/currentUser/tmp/hap-lts-stable/ClashBox-openharmony-test-signed-hap/` + (re-fetch: `gh run download 31545071671 -R jerry-271828/ClashBox`) +- Identity: bundle `org.xbgroup.clashboxLTS`, versionName `1.7.4-lts-stable.1`, + versionCode 1007048 — coexists with store V2 (`org.xbgroup.clashbox`). + +## 1. Install + +Open the .hap in Files. If retail HarmonyOS rejects the OpenHarmony test key: +put Huawei developer material into the six repo secrets (`HAP_SIGNING_P12_B64`, +`HAP_SIGNING_CERT_B64`, `HAP_SIGNING_PROFILE_B64`, `HAP_KEY_ALIAS`, +`HAP_KEY_PASSWORD`, `HAP_STORE_PASSWORD`) and re-run the workflow +(`workflow_dispatch` supported) — it then produces a device-valid signed HAP. +Do not commit secrets. + +## 2. Prepare + +1. Stop the store V2 ClashBox (only one VPN at a time). +2. In the LTS app: import the same profile (e.g. the local `sub.txt` file or + subscription URL), select a node, start the VPN. +3. Confirm `127.0.0.1:7890` (mixed) and `127.0.0.1:9090` (controller) respond. + +## 3. Long-lived-connection validation (automated) + +From a terminal: + + sh /storage/Users/currentUser/tmp/mihomo-standalone-k3X9q/run-lts-validation.sh + +(Default 3600 s; override with `DURATION=...`. Uses `hmos_wss_probe`, 4 legs: +TUN ×2 + mixed-port ×2, all to `wss://ws.postman-echo.com/raw`, 3 s pings, +controller monitor at 20 Hz.) + +Success criteria (old V2 bug would fail within ~3 minutes): + +- zero `websocket_error` events in all four legs for the full run +- controller tracker IDs stable; no `connections: null` transitions +- works identically while you foreground / minimize / restore the LTS window + during the run (the script spans long enough to do both) + +## 4. Manual clear regression (UI) + +During a validation run: open 连接管理/Connect page → tap the clear +(connections) action. Expected: all four legs die together with FIN (that's +the intended manual behavior), controller table empties, then the LTS app +keeps working and new connections succeed. + +## 5. Codex real-world validation + +With LTS as the active VPN, run normal Codex sessions that stream >3 minutes +(e.g. a long refactor). Success = no +`peer closed connection without sending TLS close_notify` from the periodic +wipe pattern. Keep `CODEX_WS_TRANSPORT_DIAGNOSTICS=1` if you want the +transport-level proof in codex logs. + +## What to expect at a glance + +| Action | Active flows | +| --- | --- | +| nothing / UI foreground / minimize / restore | survive | +| switch node, switch rule mode, open pages | survive | +| switch/load profile (favorite tap, config-page load) | **terminated** (deliberate: config actually changes) | +| manual "clear connections" | **terminated** (deliberate) | +| core recovery after genuine core death | restarted anyway (deliberate) | + +## Validation results (2026-08-12, device run) + +Run 1 (`lts-validation-20260811T234621Z`, 23:46–00:03 UTC): 4 legs (2×TUN +fake-ip + 2×mixed 7890), 17.4 min, **zero automatic events**. At 00:03:45 the +user tapped the manual clear: all 4 legs received synchronized FIN within +4 ms — manual ClearConnections works as designed. + +Run 2 (`lts-validation-20260812T000702Z`, 00:07–01:14 UTC): 4 auto-restarting +legs, 67 min wall, ~4400 pings/pongs total: + +- **no ~180.5 s periodic wipe** (the V2 bug would have fired ~22 times); +- **no global ClearConnections event** (no 4-leg synchronized FIN); +- proxy legs: 0 errors end-to-end; +- one isolated event at 00:32:04.8: both TUN legs reset with TCP RST 10 ms + apart after ~580–600 s life, mixed-port legs unaffected. Classified as a + TUN/gVisor-layer abortive reset (one-off, not FIN, not global, not + periodic) — distinct from the V2 bug signature; noted for tracking, one + occurrence per ~67 probe-hours... (per-run rate: 1 per 4 leg-hours). +- UI foreground/minimize/restore cycles during the runs: no effect on flows. diff --git a/UPSTREAM-ISSUE-DRAFT.md b/UPSTREAM-ISSUE-DRAFT.md new file mode 100644 index 00000000..f38c70af --- /dev/null +++ b/UPSTREAM-ISSUE-DRAFT.md @@ -0,0 +1,59 @@ +# 上游 Issue 草稿(提交至 xiaobaigroup/ClashBox,可关联 #158) + +## 标题 + +[问题报告 BUG] 商店版(V2/2.0.x)每隔约 180.5 秒周期性清空全部活动连接(长连接被同步 FIN) + +## 正文 + +### 现象 + +ClashBox 商店版(org.xbgroup.clashbox,V2/2.0.x)运行时,所有活动连接(TUN/Fake-IP、 +localhost mixed/socks 入站、无关的不同远端、不同建立时长的连接)会在同一时刻被 +关闭。应用侧表现为有序 FIN:read()==0、POLLRDHUP、SO_ERROR==0、TCP_INFO=CLOSE_WAIT; +TLS 上层(rustls)报 "peer closed connection without sending TLS close_notify"。 +Mihomo 控制器在同一时刻 connections 变为 null(全部 tracker 被移除),随后立刻出现 +全新 ID 的连接。ClashBox 两个进程、vpn-tun 网卡、路由表、系统网络回调全部正常。 + +### 周期 + +对 8 个 wipe 时刻做最小二乘拟合:周期 180.5004 s,52 个周期内残差 < ±0.5 s。 +这是软件定时器,不是网络抖动。网格相位锚定在 ClashBox(VPN/核心)启动时刻 ++约 3 秒,而不是绝对墙钟;重启 ClashBox 后网格相位随新实例平移。 + +### 关键判据(可复核) + +1. 对照实验(同机同网):ClashBox 关闭时,独立运行的 mihomo v1.19.27(相同订阅 + 配置)+ 探针存活 11 个完整网格周期无任何 wipe;ClashBox 开启后,每个可观测 + 网格周期都会清掉全部连接(包括走 ClashBox VPN 的独立 mihomo 的连接)。 +2. 每个 wipe 时刻,空闲的 HTTP/1.1 keep-alive 控制器连接(127.0.0.1:9090)存活。 + 若是非 patch 的 Load(method 12):hub.ApplyConfig → route.ReCreateServer → + httpServer.Close(),会关闭该连接(metacubex/http v0.1.6 server.go:3082)。 + 因此周期性 RPC 不是非 patch Load。 +3. wipe 时刻核心日志中**没有** "RESTful API listening at"(每次 ReCreateServer + 都会打这行 info 日志,hub/route/server.go:174)。再次排除 Load。 +4. 综合 2、3:周期性 RPC 是 method 11(ClearConnections)。公开 LTS 源码中它的 + 唯一调用方是"连接管理"页面的手动清理按钮(Connect.ets:147-157)。因此周期性 + 调用方只存在于未公开的 V2 代码中。 +5. 该周期任务只在 ClashBox **UI 进程正在执行**(前台,或持有后台保活:长时任务/ + 模拟画中画等)时运行;UI 进程被冻结(CPU 计数完全不动)时,网格 wipe 停止, + 但 VPN 转发正常。 + +### 影响 + +- 任何超过约 180 s 的长连接(WebSocket、SSE、HTTP/2 长连接、下载、SSH 等) + 必然在下一个网格时刻被掐断;对 TLS 上层表现为 UnexpectedEof。 +- 正常用户(开启后台保活)全天候受影响;表现为"代理周期性地全部断线又瞬间恢复"。 + +### 请求维护者确认 + +V2 中是否存在一个约 180 s 周期的 ArkTS 定时任务,会经 clash_go.sock 调用 +ClearConnections(method 11)?(候选方向:连接管理/状态同步/健康检查/自动更新 +相关逻辑。)该定时任务不应在例行执行中清空全部活动连接;手动清理功能本身需要保留。 + +### 环境 + +- HarmonyOS PC(鸿蒙电脑),HongMeng Kernel 1.12.0;商店版 ClashBox(V2 线, + 控制器报告 Mihomo 1.19.27)。 +- 注:公开仓库当前为 LTS 线(1.7.4/master 1fdc47eb),与商店版无源码对应关系; + 以上 2、3 两条判据依赖商店版内核在这两个路径上与公开 v1.19.27 行为一致。 diff --git a/VERIFICATION-PROTOCOL.md b/VERIFICATION-PROTOCOL.md new file mode 100644 index 00000000..d480df0d --- /dev/null +++ b/VERIFICATION-PROTOCOL.md @@ -0,0 +1,81 @@ +# ClashBox periodic-wipe — remaining verification protocol (manual steps) + +Automated evidence collection has reached the limit of what the unprivileged +shell can observe. The steps below require GUI access to the device. + +## A. What is already established (no further action needed) + +1. With ClashBox OFF: standalone mihomo + probes survive 11+ full grid cycles. +2. With ClashBox ON: wipes occur on a ~180.5004 s grid, phase-anchored ~3 s + after the VPN/core process start. +3. The periodic RPC is ClearConnections (method 11) with high confidence: + - an idle controller keep-alive connection survived an epoch (a non-patch + Load would have closed it via httpServer.Close); + - no "RESTful API listening at" log line at the epoch (a non-patch Load + would have emitted one via ReCreateServer). +4. The periodic caller does not exist in public LTS source → V2-specific. +5. Under the newest instance the wipe fired at 12:50:52 (≈6 cycles after the + 12:32:46 VPN start) and then stopped firing at 12:53:53/12:56:53 — the + caller's activation is conditional (see D). + +## B. Optional but decisive: install the instrumented LTS build + +Purpose: (a) prove public LTS does not wipe (V2-specificity by experiment, not +just by source audit); (b) if it does wipe, the [NETDIAG] log names the method. + +1. Get the HAP: GitHub Actions run 31484200385 (jerry-271828/ClashBox, branch + diag/close-trigger-instrumented), artifact `ClashBox-openharmony-test-signed-hap`. + (Local copy may be incomplete — downloads die at wipe epochs; use a + resumable downloader, or download while VPN is off.) +2. Install: open the .hap in Files. It is signed with the OpenHarmony test key. + If installation is refused, add Huawei developer signing material as the six + `HAP_SIGNING_*` repo secrets and re-run the workflow (docs/ci-hap-signing.md). +3. Import any working profile, start the VPN (installed V2 must be stopped + first — one VPN at a time). +4. Watch the core log (the wrapper exposes the mihomo log stream; or capture + hilog) for lines containing `[NETDIAG]`: + - if NO `close_all_connections_begin` appears across ≥5 predicted epochs → + LTS does not wipe → periodic caller is V2-only (expected); + - if it appears, the preceding `ipc_request_received method=N` names the + method and the reason string names the path. + +## C. V2 settings bisection (A/B/A) + +The wipe grid restarts its phase when ClashBox restarts. Use that: after each +settings change, fully stop and start ClashBox, note the VPN process start +moment, and predict epochs as start+~3 s + n·180.5004 s. + +For EACH candidate below: disable it → restart ClashBox → keep ≥2 long-lived +WSS probes (TUN + 127.0.0.1:7890) running → watch ≥5 predicted epochs +(~15 min). If wipes stop, re-enable and confirm they return (A/B/A). + +Candidate order (most plausible first, given the README notes that 核心恢复 is +auto-enabled by 后台运行-模拟画中画): + +1. 后台运行 / 模拟画中画 (Background run / simulated PiP) and any 核心恢复 + (core recovery) option. +2. 订阅/配置自动更新 (profile auto-update) — set to off, or a long interval. +3. 通知/实况窗 (permanent notification / LiveView) toggles. +4. 长时后台任务/模拟下载/模拟定位 (other background-keepalive modes). +5. Any 连接管理/自动清理/网络优化-style toggle present only in V2. + +The epoch recorder (`run-epoch-watch.sh`) automates the detection: it keeps +auto-restarting probes through both paths and logs every wipe with ms +timestamps; just leave it running during the bisection. + +## D. Live hypothesis under test (as of 12:58 UTC) + +The caller fires every ~180.5 s only while some condition holds (it held +11:40–11:52 and at 12:50:52, then stopped). The lifecycle hilog capture +(`epoch-watch-*/clashbox-lifecycle.hilog`) timestamps UI activity of the +ClashBox process; correlate the next active/inactive transition with what the +user was doing (app foreground/background, PiP shown/closed, screen on/off). + +## E. Upstream report + +File to xiaobaigroup/ClashBox referencing issue #158; include: +- the grid fit (180.5004 s, ±0.5 s over 52 cycles); +- the OFF/ON A/B (11 clean cycles vs every-epoch wipes); +- the two method-11 discriminators; +- the phase-anchoring to instance start; +- this protocol's bisection outcome once known. diff --git a/build-profile.json5 b/build-profile.json5 index f4dfcc31..f1734f5f 100644 --- a/build-profile.json5 +++ b/build-profile.json5 @@ -28,7 +28,6 @@ } } ], - // 产品配置 "products": [ { "name": "default", @@ -43,8 +42,7 @@ }, "externalNativeOptions": { "abiFilters": [ - "arm64-v8a", - "x86_64" + "arm64-v8a" ] } } @@ -62,8 +60,7 @@ }, "externalNativeOptions": { "abiFilters": [ - "arm64-v8a", - "x86_64" + "arm64-v8a" ] } } @@ -71,7 +68,7 @@ ], "buildModeSet": [ { - "name": "debug", + "name": "debug" }, { "name": "release" @@ -85,7 +82,6 @@ "targets": [ { "name": "default", - // 入口模块的产品信息配置 "applyToProducts": [ "default", "release" @@ -95,11 +91,11 @@ }, { "name": "proxy_core", - "srcPath": "./proxy_core", + "srcPath": "./proxy_core" }, { "name": "xb_components", "srcPath": "./xb_components" } ] -} \ No newline at end of file +} diff --git a/docs/ci-hap-signing.md b/docs/ci-hap-signing.md new file mode 100644 index 00000000..6d725bc4 --- /dev/null +++ b/docs/ci-hap-signing.md @@ -0,0 +1,38 @@ +# Cloud HAP signing + +The GitHub Actions workflow always produces a signed HAP: + +- With no signing secrets, it uses the public OpenHarmony SDK test key. That + package is intended for OpenHarmony development devices/images that trust the + public test key. +- When all six secrets below are present, it uses the supplied Huawei developer + material and produces a package suitable for the devices covered by that + signing profile. + +Configure these repository Actions secrets: + +| Secret | Value | +| --- | --- | +| `HAP_SIGNING_P12_B64` | Base64-encoded `.p12` keystore | +| `HAP_SIGNING_CERT_B64` | Base64-encoded application `.cer` certificate | +| `HAP_SIGNING_PROFILE_B64` | Base64-encoded signed `.p7b` profile | +| `HAP_KEY_ALIAS` | Keystore key alias | +| `HAP_KEY_PASSWORD` | Private-key password | +| `HAP_STORE_PASSWORD` | Keystore password | + +All six values must be configured together. A partial configuration deliberately +fails instead of silently publishing a test-signed package. + +# Unsigned release HAP + +A separate workflow, `release-hap.yml`, builds the core from the same pinned +revisions and publishes the **unsigned** release HAP as a GitHub Release: + +- Pushing a version tag (`1.2.3`, `1.7.4-lts-stable.1`, ...) creates a stable + release named after the tag. +- A manual `workflow_dispatch` run refreshes the rolling `nightly` prerelease. + +The release notes and `INSTALLATION-NOTES.txt` in each release explain that the +package is unsigned. An unsigned HAP cannot be installed on commercial +HarmonyOS devices: re-sign it in DevEco Studio or with `hap-sign-tool` (the +`scripts/ci/sign-hap.sh` flow above) before installing. diff --git a/docs/lts-connection-lifecycle-audit.md b/docs/lts-connection-lifecycle-audit.md new file mode 100644 index 00000000..e1690617 --- /dev/null +++ b/docs/lts-connection-lifecycle-audit.md @@ -0,0 +1,50 @@ +# LTS connection-lifecycle audit + +Base: public ClashBox LTS, `master` @ `1fdc47eb9b3bdb715fb04c4b44e1d5238faf83e0` +(app code identical in `ci/ohos-core-build`; only CI/build metadata differs). + +Question: does public LTS contain any **automatic/periodic** caller that closes +all active Mihomo trackers? + +Answer: **No.** Every destructive path is either manual or a deliberate +profile-switch/recovery action. There is no timer, watchdog, lifecycle +callback, or background task in LTS that invokes a global connection close. + +## Destructive primitives (core wrapper, Go) + +| Path | Effect | +| --- | --- | +| `proxy_core/src/flclash/ipc.go:146-148` — RPC 11 `ClearConnections` → `handleCloseConnections()` | closes every tracker | +| `proxy_core/src/flclash/hub.go:247-269` — `handleCloseConnectionsUnLock` | iterates `statistic.DefaultManager`, `Tracker.Close()` | +| `proxy_core/src/flclash/common.go:349-366` — `applyConfig` with `is-patch=false` | calls `handleCloseConnectionsUnLock` before `hub.ApplyConfig` | +| `proxy_core/src/flclash/ipc.go:142-145` — RPC 10 `CloseConnection` | single tracker (manual per-item close) | + +## All ArkTS callers of those primitives + +| Caller | File | Trigger | Class | +| --- | --- | --- | --- | +| `ClashViewModel.clearConnections` ← clean button | `components/More/Connect.ets:147-157` | user taps "clear connections" | **manual, keep** | +| non-patch `loadConfig(false)` | `entryability/ClashViewModel.ets:525` (`initProfile`) | app startup, no flows exist | harmless | +| non-patch `loadConfig(false)` | `entryability/ClashViewModel.ets:413` (`ReStartVpn`) | genuine core recovery (VPN restarts anyway) | intentional | +| non-patch `loadConfig(false)` | `components/Home/FavoriteConfiguration.ets:59` | user taps a favorite profile | deliberate profile switch | +| non-patch `loadConfig(false)` | `pages/ConfigurationPage.ets:752` | user loads a profile | deliberate profile switch | +| patch `loadConfig(true)` (no close) | `pages/HomePage.ets:433`, `entryability/EntryAbility.ets:277`, `components/More/Resources.ets:207` | mode switch / card init / resource update | non-destructive | + +## Automatic paths checked and cleared + +- `ConfigAutoUpdateService` (60 s `setInterval`): downloads and saves due URL + profiles, then emits `FetchProfile` — the handler only refreshes the UI + profile list (`pages/Index.ets:1125-1127`). **No core reload.** +- Core-recovery `setInterval(1000)` (`entryability/EntryAbility.ets:481`): + fires `ChangeCore`+`ReStartVpn` only when `socketProxy.active == false` + (private RPC connect failure). Not periodic in healthy operation. +- All page timers (0.9 s duration display, 1 s traffic, 1.5 s notification, + 9 s connections-page query): query-only RPCs, cleared on page disappear. +- No `180000`/`3 * 60`/3-minute constant anywhere in the tree. + +## Conclusion + +Public LTS needs **no behavioral patch** for the periodic-wipe bug: the V2 +~180.5 s automatic `ClearConnections` caller does not exist here. This branch +therefore ships the LTS feature set unmodified; validation focuses on proving +long-lived connections survive under this build. diff --git a/docs/lts-stable-build.md b/docs/lts-stable-build.md new file mode 100644 index 00000000..93297ed5 --- /dev/null +++ b/docs/lts-stable-build.md @@ -0,0 +1,36 @@ +# ClashBox LTS stable build (long-lived connections) + +This branch (`fix/lts-stable-long-connections`) packages the **public ClashBox +LTS** line as a stable replacement for the unpublished store V2 build. + +## Why + +The store V2 build (`org.xbgroup.clashbox`, 2.0.x) periodically wipes all +active connections: a UI-process ArkTS timer fires about every 180.5 s while +the UI runtime is executing and invokes RPC `ClearConnections` (method 11) on +the embedded core. Every active TCP/WebSocket flow — TUN/Fake-IP and localhost +mixed-port alike — then receives an orderly FIN within the same millisecond +window. Details: `INVESTIGATION-2026-08-11-synchronized-fin.md` (not part of +upstream source). + +Public LTS contains the destructive `ClearConnections` handler only behind the +**manual** "clear connections" action and has no automatic caller +(audit: `docs/lts-connection-lifecycle-audit.md`). No behavioral patch is +required; this build deliberately changes nothing in the app logic. + +## What this build is + +- Source: public LTS `master` @ `1fdc47eb` + the reproducible-build CI from + `ci/ohos-core-build` (vendored `xb_components`, pinned core/gVisor/Go + toolchain, OpenHarmony test-key or `HAP_SIGNING_*` secret signing). +- Bundle: `org.xbgroup.clashboxLTS` — coexists with store V2; run only one + VPN at a time. +- Version identity: `1.7.4-lts-stable.1` (versionCode 1007048). +- Instrumented variant for RPC/close-event tracing lives separately on + `diag/close-trigger-instrumented` and is not part of this build. + +## Validation focus + +Long-lived WSS/TCP flows must survive indefinitely across UI foreground, +minimize/restore, and background use; manual "clear connections" must still +work. See the validation section of the investigation notes. diff --git a/docs/tray-desktop-feature.md b/docs/tray-desktop-feature.md new file mode 100644 index 00000000..16b91c84 --- /dev/null +++ b/docs/tray-desktop-feature.md @@ -0,0 +1,118 @@ +# 桌面托盘(系统状态栏图标)功能设计 — ClashBox LTS + +分支:`fix/lts-stable-long-connections`。本功能基于 HarmonyOS NEXT 官方的 +PC 托盘(状态栏图标)机制实现,参考官方示例(PCStatusBar,`@kit.DeskTopExtensionKit`), +并复用 ClashBox 既有代理状态与切换实现,不引入独立的代理管理系统。 + +## 能力检测(不依赖设备型号) + +```text +DesktopEnvironment.isDesktopEnvironment() + = canIUse('SystemCapability.PCService.StatusBarManager') // 托盘服务能力(运行时探测) + AND 主窗口处于桌面式窗口模式(WindowMode 既有检测 或 getWindowStatus() ∈ {FLOATING, MAXIMIZE}) +``` + +- **HarmonyOS PC**:托盘能力 ✓ + 窗口自由悬浮/最大化 ✓ → 启用 +- **MatePad Edge PC/桌面模式**:实现预期为系统在该模式下提供 PC 服务(托盘能力 ✓)、 + 窗口自由悬浮 ✓ → 启用。**该预期须真机实测验证** —— 官方文档仍将托盘/终止回调 + 描述为对 2-in-1 设备生效,MatePad Edge 两种模式下系统实际暴露的能力以 + `TrayDiag` 诊断日志为准(见下)。 +- **MatePad Edge 平板模式 / 普通平板 / 手机**:预期托盘能力 ✗ → 不启用,保持既有行为 +- 未硬编码任何设备型号;能力探测优先于设备类别判断 + +## 关闭到托盘 vs 应用退出(职责分离) + +| 操作 | 路由 | 结果 | +| --- | --- | --- | +| 窗口 X(自定义按钮) | TopBar → `hideAbility()` | 隐藏到托盘,VPN/核心/连接原样运行 | +| 最近任务/窗口级关闭 | `EntryAbility.onPrepareToTerminate` → 返回 `true` | 隐藏到托盘 | +| 系统托盘"退出"项 / 任务栏(Dock)右键关闭 | `AbilityStage.onPrepareTermination`(实现后优先走此回调) → `exitApp()` + `TERMINATE_IMMEDIATELY` | 标记退出 → 注销监听 → 移除托盘 → 回调返回,由系统正常终止应用;`ClashVpnAbility.onDestroy` 是 TUN 的清理归属点,Mihomo 随应用进程退出 | +| 兜底:托盘退出未走 AbilityStage 时 | `ClashTrayHolderAbility.onPrepareToTerminate` → 幂等 `exitApp()` + 返回 `false` | 同上;与 AbilityStage 共享退出守卫,不会重复清理 | + +- 应用**不再自建**托盘右键"退出"项 —— 系统在托盘右键菜单自动提供"退出", + 避免重复退出入口。 +- 隐藏/恢复路径**不触发** `loadConfig`、`clearConnections`、`StopVpn`、 + `ReStartVpn` 等任何连接清理逻辑,长连接修复(lts-stable)不受影响。 + +## 托盘交互 + +- **左键**:`statusBarIconClick` 事件(官方定义:返回 `iconClickType`,取值 `leftClick`) + → `showAbility()` 恢复既有主窗口。 +- **右键**:`updateStatusBarMenu` 动态菜单 — + - 分组1:Selector 类型代理分组(隐藏分组除外),子菜单为节点列表, + 当前选中节点带 `✓ ` 前缀;节点切换调用与代理页完全相同的 + `ClashViewModel.changeProxy(profile, g, p)` + 卡片持久化。 + - 分组2:打开ClashBox(notifyOnly + menuCode,由 `rightMenuClick` 事件处理); + "退出"使用系统自带项。 +- 菜单同步:`ClashViewModel.changeProxy` 与 `loadProfileAndConfig` 成功后发送 + `EventKey.TrayMenuRefresh`(10025),TrayManager 防抖(300ms)重建菜单。 + +## 保活绑定 + +`ClashTrayHolderAbility` 以 `ATTACH_TO_STATUS_BAR_ITEM + STARTUP_HIDE` 启动 +(不新建进程): + +1. 将应用进程附着到状态栏图标 —— 托盘对用户可见、可退出,进程可后台保活 + (HarmonyOS PC 不允许不可见进程后台运行,托盘是官方保活通道); +2. 是 `showAbility()/hideAbility()` 的前置条件 + (错误码 16000067:调用方须以 ATTACH_TO_STATUS_BAR_ITEM 模式启动)。 + +## 平台限制(记录在案) + +- 右键菜单总一级菜单项 ≤ 20、单一级菜单子项 ≤ 20 → 代理分组截断为 ≤18 组、 + 每组 ≤19 个节点 + "更多节点…"入口打开主界面。 +- `hoverTips` 为 API 22(6.0.2) 能力,本项目 targetSdk 20 未使用。 +- `removeFromStatusBar` 在无前台窗口时返回 1010710004 → 退出时忽略该错误, + 进程终止后系统自动清理图标。 +- `AbilityStage.onPrepareTermination` / `UIAbility.onPrepareToTerminate` + 官方说明仅在 2-in-1 设备生效;不生效的设备上依赖 X 按钮的显式拦截 + (既有自定义窗口按钮)。 +- `ApplicationContext.killAllProcesses()` 仅适合异常场景且不会执行完整正常生命周期, + 因此系统托盘/Dock的预终止回调不再调用它;它只保留给应用内既有的"直接退出"模式。 +- 正常退出路径保留了 `EntryAbility` / VPN Extension 的 `onDestroy` 生命周期机会;崩溃、 + 强制停止等异常退出不保证回调。MatePad Edge 上系统托盘退出的实际回调顺序、TUN 与 + Mihomo 的消失仍须通过下述真机步骤确认。 +- 本项目未注册 `windowStage.on('windowStageClose')`:PC 模式下系统标题栏 + (三键栏)被既有代码隐藏(`setWindowDecorVisible(false)` 等),原生关闭按钮 + 不可达,关闭路径为自定义 X 按钮 + 上述终止回调。 +- 托盘图标优先使用 `rawfile/clash_status_white.svg / clash_status_black.svg` + (24vp 黑白状态栏图标),解码失败时回退 `box_cat_round.png`。 + +## 真机能力验证(TrayDiag 本地诊断日志) + +在 MatePad Edge 平板模式与 PC/桌面模式分别启动一次,抓取本地日志: + +```text +hilog | grep TrayDiag +``` + +关注以下行(仅本地日志,不采集不上传): + +```text +TrayDiag canIUse(SystemCapability.PCService.StatusBarManager) = +TrayDiag isDesktopEnvironment deviceType=<...> sdkApi=<...> trayCapable=<...> desktopWindow=<...> -> +TrayDiag trayInit result= +TrayDiag holderAbility start= +``` + +- 平板模式预期:`canIUse=false`、`isDesktopEnvironment -> false`、无 `trayInit` 行; +- PC 模式预期:`canIUse=true`、`isDesktopEnvironment -> true`、`trayInit result=success`。 +- 若 PC 模式实测与预期不符(如 canIUse=false),记录日志后以实测为准调整判定条件。 + +## 生命周期不变量 + +- 托盘资源(图标/监听/事件订阅)每个应用生命周期仅初始化一次(`TrayManager.initialized`); + 仅在真正退出时销毁(`onDestroy` / `exitApp`)。 +- hide/show 反复执行不产生重复图标、窗口、监听器。 +- 长连接保护:托盘全部路径均为窗口操作或只读查询(`queryProxyGroups`), + 与 `docs/lts-connection-lifecycle-audit.md` 的破坏性路径清单无交集。 + +## 手动验收步骤 + +1. HarmonyOS PC / MatePad Edge(PC模式) 启动 ClashBox LTS,连接代理并建立长连接; +2. 点击窗口 X → 窗口消失、托盘图标仍在、流量与连接不中断; +3. 左键托盘 → 同一窗口恢复(重复多次); +4. 右键托盘 → 分组菜单出现,切换节点 → 立即生效、菜单 ✓ 更新、主界面同步; +5. 长列表配置 → 分组显示"更多节点…"入口; +6. 右键托盘 → 系统"退出"项 → 应用与托盘图标完全终止(不要点应用自建的项——不存在); +7. MatePad Edge 切回平板模式 → 无托盘、行为与旧版一致(用 TrayDiag 日志确认)。 diff --git a/entry/src/main/ets/common/EventHub.ts b/entry/src/main/ets/common/EventHub.ts index b8e5d74b..0df0b07c 100644 --- a/entry/src/main/ets/common/EventHub.ts +++ b/entry/src/main/ets/common/EventHub.ts @@ -25,7 +25,16 @@ export enum EventKey{ AddConfig = 10021, TestAllDelay = 10022, SwitchButtonPosition = 10023, - ReLoadAccessControl = 10024 + ReLoadAccessControl = 10024, + // 托盘右键菜单刷新(代理分组/选中节点变化后触发,供 TrayManager 重建菜单) + TrayMenuRefresh = 10025, + // 内核确认节点切换后,同步所有应用内界面持有的 Profile/ProxyGroup 快照 + ProxySelectionChanged = 10026 +} + +export interface ProxySelectionChangedData { + group: string + proxy: string } export class EventHub{ @@ -42,4 +51,4 @@ export class EventHub{ static off(key: EventKey) { emitter.off(key) } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/common/utils/DesktopEnvironment.ets b/entry/src/main/ets/common/utils/DesktopEnvironment.ets new file mode 100644 index 00000000..545731c2 --- /dev/null +++ b/entry/src/main/ets/common/utils/DesktopEnvironment.ets @@ -0,0 +1,80 @@ +/** + * @description 桌面环境检测 - 用于判断当前窗口是否处于桌面级窗口模式。 + * 状态栏 syscap 仅作为诊断信息;MatePad Edge 桌面窗口实测可使用状态栏 API, + * 但 canIUse(SystemCapability.PCService.StatusBarManager) 返回 false。 + * 实际能力以 statusBarManager.addToStatusBar 的返回为准。 + */ +import { window } from '@kit.ArkUI'; +import { deviceInfo } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +/** 本地诊断日志标签(仅本地日志,不采集不上传):hilog | grep TrayDiag */ +const TAG = 'TrayDiag' + +export class DesktopEnvironment { + /** 系统状态栏服务 syscap 名称(仅用于诊断,不作为硬门槛) */ + private static readonly STATUS_BAR_SYSCAP: string = 'SystemCapability.PCService.StatusBarManager' + /** syscap 上报结果缓存(设备能力在运行期内不变) */ + private static trayCapability: boolean | undefined = undefined + + /** + * @description 读取系统是否上报状态栏 syscap,仅用于诊断。 + * 该值在 MatePad Edge 桌面窗口模式存在假阴性,不能据此跳过真实 API 调用。 + */ + static isTraySupported(): boolean { + if (DesktopEnvironment.trayCapability === undefined) { + try { + DesktopEnvironment.trayCapability = canIUse(DesktopEnvironment.STATUS_BAR_SYSCAP) + } catch (e) { + console.error(`DesktopEnvironment 能力探测失败,message: ${e.message}`) + DesktopEnvironment.trayCapability = false + } + hilog.info(0x0000, TAG, + `canIUse(${DesktopEnvironment.STATUS_BAR_SYSCAP}) = ${DesktopEnvironment.trayCapability}`) + } + return DesktopEnvironment.trayCapability + } + + /** + * @description 主窗口当前是否处于桌面式窗口模式(自由悬浮/最大化)。 + * 优先复用 EntryAbility 既有的 PC 窗口模式检测结果(WindowMode), + * 未检测时回退到窗口状态探测。 + */ + static isDesktopWindowMode(windowClass?: window.Window): boolean { + // 复用 EntryAbility.onWindowModeChange 的既有检测结果 + const windowMode = AppStorage.get('WindowMode') + if (windowMode === true) { + return true + } + // 回退:直接探测当前窗口状态(自由窗口/最大化 -> 桌面式窗口环境) + if (windowClass) { + try { + const status = windowClass.getWindowStatus() + hilog.info(0x0000, TAG, + `WindowMode=${windowMode} windowStatus=${status}(FLOATING=${window.WindowStatusType.FLOATING},MAXIMIZE=${window.WindowStatusType.MAXIMIZE})`) + return status === window.WindowStatusType.FLOATING || + status === window.WindowStatusType.MAXIMIZE + } catch (e) { + console.error(`DesktopEnvironment 窗口状态探测失败,message: ${e.message}`) + hilog.error(0x0000, TAG, `getWindowStatus 探测失败: ${e.code ?? ''} ${e.message}`) + } + } + return false + } + + /** + * @description 是否为桌面环境:以桌面式窗口模式为准。 + * 状态栏能力由调用方实际执行 addToStatusBar 并处理 BusinessError 验证。 + * HarmonyOS PC、MatePad Edge PC 模式 -> true; + * 普通平板/手机/MatePad Edge 平板模式 -> false(保留既有行为)。 + */ + static isDesktopEnvironment(windowClass?: window.Window): boolean { + const trayCapable = DesktopEnvironment.isTraySupported() + const desktopWindow = DesktopEnvironment.isDesktopWindowMode(windowClass) + const result = desktopWindow + // syscap 结果保留在诊断日志中,但不再阻断 MatePad Edge 的真实状态栏 API 探测。 + hilog.info(0x0000, TAG, + `isDesktopEnvironment deviceType=${deviceInfo.deviceType} sdkApi=${deviceInfo.sdkApiVersion} syscapReported=${trayCapable} desktopWindow=${desktopWindow} -> ${result}`) + return result + } +} diff --git a/entry/src/main/ets/common/utils/HHmmssTimer.ets b/entry/src/main/ets/common/utils/HHmmssTimer.ets index 6e920321..4afd193c 100644 --- a/entry/src/main/ets/common/utils/HHmmssTimer.ets +++ b/entry/src/main/ets/common/utils/HHmmssTimer.ets @@ -21,16 +21,22 @@ export class Timer { public start(callback?: (time: number) => void, startTime?: number): void { this.callback = callback - if (startTime) { + if (startTime !== undefined) { this.startTime = startTime } else { this.startTime = Date.now() - this.elapsed } + // 重复启动只保留一个调度器,避免多个 interval 交错刷新同一显示值。 + if (this.timerId !== undefined) { + clearInterval(this.timerId) + } this.timerId = setInterval(() => this.updateTimer(), 900) + // 首次启动立即同步,避免先显示 00:00:00、下一拍才跳到真实运行时长。 + this.updateTimer() } public pause(): void { - if (this.timerId) { + if (this.timerId !== undefined) { this.elapsed = Date.now() - this.startTime clearInterval(this.timerId) this.timerId = undefined @@ -42,6 +48,13 @@ export class Timer { this.start(this.callback) } + /** @description 前台恢复前立即发布当前运行时长,消除后台定时器节流造成的陈旧首帧。 */ + public refresh(): void { + if (this.timerId !== undefined) { + this.updateTimer() + } + } + private updateTimer(): void { if (this.callback) { const timeString = this.getTime() @@ -50,7 +63,7 @@ export class Timer { } public reset(): void { - if (this.timerId) { + if (this.timerId !== undefined) { clearInterval(this.timerId); this.timerId = undefined; } @@ -64,5 +77,3 @@ export class Timer { } } - - diff --git a/entry/src/main/ets/common/utils/ProxyKeepAliveService.ets b/entry/src/main/ets/common/utils/ProxyKeepAliveService.ets new file mode 100644 index 00000000..45465dd1 --- /dev/null +++ b/entry/src/main/ets/common/utils/ProxyKeepAliveService.ets @@ -0,0 +1,155 @@ +import { Context, WantAgent, wantAgent } from "@kit.AbilityKit"; +import { backgroundTaskManager } from "@kit.BackgroundTasksKit"; +import { BusinessError, deviceInfo } from "@kit.BasicServicesKit"; +import { hilog } from "@kit.PerformanceAnalysisKit"; +import { UIConfig } from "../../entryability/AppState"; + +const TAG = 'ProxyKeepAliveService' +// 系统撤销长时任务后的重试参数 +const RETRY_DELAY_MS = 3000 +const MAX_RETRY = 5 + +/** + * 代理运行期间的长时任务自动保活。 + * + * 背景:HarmonyOS 在机器锁定/息屏后会对后台应用进程做冻结与回收, + * 未持有长时任务(continuous task)的应用进程会被系统杀掉。ClashBox 的 + * 内核(mihomo)运行在 UI 进程(前台模式)或由 UI 进程托管全部控制面 RPC、 + * 托盘与 1s 恢复看门狗;UI 进程一旦被回收,代理随之中断, + * 依赖代理的应用(如 Codex)即无法连接。 + * + * 策略:代理启动时自动申请 TASK_KEEPING 长时任务(PC/2in1/平板可用), + * 代理停止时释放。与用户手动开启的 模拟下载/模拟定位/任务保持 保活互斥, + * 避免同一 UIAbility 重复申请长时任务。 + * 系统若撤销长时任务,通过 continuousTaskCancel 事件自动重新申请(有界重试)。 + */ +export class ProxyKeepAliveService { + // 当前是否持有本服务申请的长时任务 + private taskStarted: boolean = false + // 代理运行期间是否应持有长时任务(start/stop 维护) + private wanted: boolean = false + // continuousTaskCancel 监听是否已注册 + private cancelHandlerRegistered: boolean = false + // 系统连续撤销时的重试计数 + private retryCount: number = 0 + + /** + * 代理启动后调用(幂等):申请长时任务。 + */ + async start(context: Context): Promise { + this.wanted = true + this.retryCount = 0 + await this.requestTask(context, 'proxy-start') + } + + /** + * 代理停止后调用(幂等):仅释放本服务自己申请的长时任务, + * 不影响用户手动开启的 模拟下载/模拟定位/任务保持 长时任务。 + */ + async stop(context: Context): Promise { + this.wanted = false + this.retryCount = 0 + if (!this.taskStarted) { + return + } + this.taskStarted = false + try { + await backgroundTaskManager.stopBackgroundRunning(context) + hilog.info(0xFF00, TAG, '长时任务已释放') + } catch (err) { + let e = err as BusinessError + hilog.error(0xFF00, TAG, + `长时任务释放失败 code=${e.code} message=${e.message}`) + } + } + + /** + * 锁屏/解锁等系统状态变化时调用:若系统撤销了长时任务则重新申请。 + */ + async reassert(context: Context): Promise { + if (!this.wanted) { + return + } + await this.requestTask(context, 'system-state-reassert') + } + + private async requestTask(context: Context, reason: string): Promise { + if (this.taskStarted) { + return + } + // TASK_KEEPING(计算任务)在 API 20 及之前仅对 PC/2in1 设备开放(平板沿用 + // 应用内既有「任务保持」开关的设备范围)。手机等设备跳过自动保活, + // 保留原有 模拟下载/模拟定位 的手动保活行为。 + const type = deviceInfo.deviceType + if (type !== '2in1' && type !== 'tablet') { + hilog.info(0xFF00, TAG, + `设备类型 ${type} 不支持 TASK_KEEPING 自动保活,跳过`) + return + } + // 用户已手动开启任意后台保持开关时,避免同一 UIAbility 重复申请长时任务 + const uiConfig = AppStorage.get('uiConfig') + if (uiConfig && uiConfig.Enablebackgrounder) { + hilog.info(0xFF00, TAG, '用户已启用后台保持(模拟下载/定位/任务保持),跳过自动保活') + return + } + this.registerCancelListener(context) + const wantAgentInfo: wantAgent.WantAgentInfo = { + wants: [ + { + bundleName: context.applicationInfo.name, + abilityName: 'EntryAbility' + } + ], + actionType: wantAgent.OperationType.START_ABILITY, + requestCode: 0, + wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] + }; + try { + const wantAgentObj: WantAgent = await wantAgent.getWantAgent(wantAgentInfo) + await backgroundTaskManager.startBackgroundRunning(context, + backgroundTaskManager.BackgroundMode.TASK_KEEPING, wantAgentObj) + this.taskStarted = true + this.retryCount = 0 + hilog.info(0xFF00, TAG, `长时任务申请成功 reason=${reason}`) + } catch (err) { + let e = err as BusinessError + hilog.error(0xFF00, TAG, + `长时任务申请失败 reason=${reason} code=${e.code} message=${e.message}`) + } + } + + /** + * 监听系统撤销长时任务:代理仍开启时自动重新申请(有界重试)。 + */ + private registerCancelListener(context: Context): void { + if (this.cancelHandlerRegistered) { + return + } + this.cancelHandlerRegistered = true + try { + backgroundTaskManager.on('continuousTaskCancel', + (info: backgroundTaskManager.ContinuousTaskCancelInfo) => { + hilog.warn(0xFF00, TAG, + `长时任务被系统撤销 id=${info.id} reason=${info.reason}`) + if (!this.wanted) { + return + } + this.taskStarted = false + if (this.retryCount < MAX_RETRY) { + this.retryCount++ + setTimeout(() => { + this.requestTask(context, 'cancel-retry') + }, RETRY_DELAY_MS) + } else { + hilog.error(0xFF00, TAG, '长时任务重试次数超限,放弃自动保活') + } + }) + } catch (err) { + let e = err as BusinessError + hilog.error(0xFF00, TAG, + `注册长时任务撤销监听失败 code=${e.code} message=${e.message}`) + } + } +} + +export default new ProxyKeepAliveService() diff --git a/entry/src/main/ets/common/utils/TrayManager.ets b/entry/src/main/ets/common/utils/TrayManager.ets new file mode 100644 index 00000000..1d3ee86b --- /dev/null +++ b/entry/src/main/ets/common/utils/TrayManager.ets @@ -0,0 +1,635 @@ +/** + * @description 系统托盘控制器单例。 + * 仅在桌面环境(HarmonyOS PC / MatePad Edge PC模式)下生效: + * - 负责托盘图标的注册/注销(一次生命周期一份资源,无重复监听) + * - 左键点击 -> 恢复既有主窗口(showAbility) + * - 右键菜单 -> 复用既有代理状态(ClashViewModel/Profile)的快捷节点切换 + * - 关闭窗口 -> 隐藏到托盘,VPN/Mihomo 与既有长连接不受影响 + * - 系统退出 -> 注销监听、移除托盘,由系统完成正常生命周期终止 + * - 应用内直接退出 -> 完成同样清理后沿用既有强制退出语义 + */ +import { statusBarManager } from '@kit.DeskTopExtensionKit'; +import { image } from '@kit.ImageKit'; +import { window } from '@kit.ArkUI'; +import { common, contextConstant, StartOptions, Want } from '@kit.AbilityKit'; +import { emitter, BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { ClashConfig, ProxyGroup, ProxyMode, ProxyType } from 'proxy_core'; +import ClashViewModel from '../../entryability/ClashViewModel'; +import { AppConfig, AppState } from '../../entryability/AppState'; +import { EventHub, EventKey } from '../EventHub'; +import { ProxyItem } from '../datasources/ProxyData'; +import { Xb_ToastUtil } from 'xb_components'; +import { DesktopEnvironment } from './DesktopEnvironment'; + +const TAG = 'TrayManager' +/** 右键菜单项唯一标识 */ +const MENU_CODE_OPEN = 'clashbox.tray.open' +const MENU_CODE_MORE = 'clashbox.tray.more' +/** + * 状态栏菜单动作必须指定一个 UIAbility。notifyOnly 只开启 rightMenuClick + * 通知,并不阻止系统拉起该 Ability,因此不能指向主界面 EntryAbility。 + */ +const MENU_ACTION_ABILITY = 'ClashTrayHolderAbility' +/** 平台限制:单个一级菜单最多20个子菜单项 */ +const MAX_SUB_MENU = 20 +/** 平台限制:所有分组一级菜单项总和不超过20个(为操作组预留2项) */ +const MAX_GROUP_MENU = 18 +/** 菜单重建防抖(ms) - 避免配置加载期间的密集刷新触发1010710003 */ +const MENU_REBUILD_DEBOUNCE = 300 + +interface TrayMenuSnapshot { + groupMenus: statusBarManager.StatusBarGroupMenu[] + codeMap: Map +} + +export class TrayManager { + private static instance: TrayManager | undefined = undefined + private context: common.UIAbilityContext | undefined = undefined + /** 托盘图标是否已添加 */ + private initialized: boolean = false + /** 保活Ability是否已完成状态栏附着 */ + private holderAttached: boolean = false + /** 正在进行的状态栏初始化;并发入口共享同一个任务,避免重复注册或提前隐藏 */ + private initialization: Promise | undefined = undefined + /** 主窗口正在隐藏/恢复,防止连续点击并发执行可见性操作 */ + private visibilityChanging: boolean = false + /** 是否正在显式退出(拦截系统终止回调时放行) */ + private exiting: boolean = false + /** menuCode -> [分组名, 节点名] 映射,重建菜单时整体替换 */ + private menuCodeMap: Map = new Map() + /** 每份已发布菜单使用独立代码,避免更新过渡期将旧菜单映射到新节点 */ + private menuGeneration: number = 0 + /** 菜单更新串行化,防止慢查询覆盖后到的最新状态 */ + private menuRefreshInProgress: boolean = false + private menuRefreshRequested: boolean = false + private rebuildTimer: number = -1 + + static getInstance(): TrayManager { + if (TrayManager.instance === undefined) { + TrayManager.instance = new TrayManager() + } + return TrayManager.instance + } + + /** @description 托盘是否已激活(图标已添加且监听已注册) */ + isActive(): boolean { + return this.initialized && this.holderAttached && !this.exiting + } + + /** @description 是否正在显式退出 */ + isExiting(): boolean { + return this.exiting + } + + /** + * @description 初始化系统托盘。整个应用生命周期仅执行一次, + * 重复调用(如窗口重建/页面重载)不会产生重复图标与监听。 + */ + async init(context: common.UIAbilityContext): Promise { + // 即使窗口模式尚未就绪也先缓存上下文,X 键可在模式就绪后补做初始化。 + this.context = context + if (this.exiting) { + return + } + // 图标已经添加但保活Ability尚未附着时,仍须等待同一个初始化任务。 + // 因此 pending 检查必须早于 initialized,避免刚启动时点击 X 偶发无响应。 + const pending: Promise | undefined = this.initialization + if (pending) { + await pending + return + } + if (this.initialized) { + return + } + // EntryAbility 缓存的主窗口对象(供桌面窗口模式探测) + const mainWindow = AppStorage.get('WindowClass') + if (!DesktopEnvironment.isDesktopEnvironment(mainWindow)) { + hilog.info(0x0000, TAG, '非桌面环境,跳过托盘初始化') + return + } + const task: Promise = this.initialize(context) + this.initialization = task + try { + await task + } finally { + this.initialization = undefined + } + } + + /** @description 按“创建条目 -> 附着进程 -> 注册监听”的顺序完成状态栏接入。 */ + private async initialize(context: common.UIAbilityContext): Promise { + // 状态栏图标(黑/白两份适配深浅色壁纸) + const icons = await this.loadStatusBarIcon(context) + // 图标加载期间可能已经收到系统退出请求,不再创建新的状态栏资源 + if (this.exiting) { + return + } + // abilityName 传空字符串:左键点击走 statusBarIconClick,直接恢复主窗口 + const operation: statusBarManager.QuickOperation = { + abilityName: '', + title: 'ClashBox', + height: 30, + moduleName: 'entry' + } + const item: statusBarManager.StatusBarItem = { + icons: icons, + quickOperation: operation + } + try { + hilog.info(0x0000, 'TrayDiag', 'addToStatusBar request') + // 使用回调重载等待系统确认,避免“API已返回但条目尚不能附着”的竞态。 + await this.addStatusBarItem(context, item) + hilog.info(0x0000, TAG, 'addToStatusBar success') + hilog.info(0x0000, 'TrayDiag', 'trayInit result=success') + } catch (err) { + const error = err as BusinessError + hilog.error(0x0000, TAG, + `addToStatusBar failed. code: ${error.code}, message: ${error.message}`) + hilog.error(0x0000, 'TrayDiag', + `trayInit result=failed code=${error.code ?? ''} message=${error.message}`) + return + } + this.initialized = true + if (this.exiting) { + this.destroy() + return + } + + // 必须等保活Ability的附着启动请求成功后才对外宣告状态栏可用。 + const holderStarted = await this.startHolderAbility() + if (this.exiting) { + return + } + if (!holderStarted) { + this.destroy() + return + } + + // 注册点击事件(仅此一次) + try { + statusBarManager.on('statusBarIconClick', this.onStatusBarIconClick) + statusBarManager.on('rightMenuClick', this.onRightMenuClick) + } catch (err) { + const error = err as BusinessError + hilog.error(0x0000, TAG, + `register statusBar event failed. code: ${error.code}, message: ${error.message}`) + // 事件未完整注册时不暴露半激活状态,并回收已添加的图标。 + this.destroy() + return + } + + // 订阅代理状态变化 -> 重建右键菜单(与主界面共用同一份代理状态) + EventHub.on(EventKey.TrayMenuRefresh, () => { + this.requestRebuildMenu() + }) + this.holderAttached = true + hilog.info(0x0000, 'TrayDiag', + 'trayInit 已完成:图标+监听+保活Ability(ATTACH_TO_STATUS_BAR_ITEM)') + // 初始构建右键菜单(代理数据就绪后由 TrayMenuRefresh 再次刷新) + this.requestRebuildMenu() + } + + /** + * @description 隐藏主窗口到状态栏(不终止应用,VPN/长连接保持)。 + * 若初始化尚未完成,先等待同一初始化任务;不以最小化伪装成功。 + */ + async hideMainWindow(context?: common.UIAbilityContext): Promise { + if (this.visibilityChanging || this.exiting) { + return + } + if (context) { + this.context = context + } + this.visibilityChanging = true + try { + const abilityContext: common.UIAbilityContext | undefined = this.context + if (!abilityContext) { + hilog.error(0x0000, TAG, 'hideAbility skipped: UIAbilityContext unavailable') + return + } + if (!this.isActive()) { + await this.init(abilityContext) + } + if (!this.isActive()) { + hilog.error(0x0000, TAG, 'hideAbility skipped: status bar attachment is not ready') + return + } + await abilityContext.hideAbility() + hilog.info(0x0000, TAG, 'hideAbility success') + } catch (err) { + const error = err as BusinessError + hilog.error(0x0000, TAG, `hideAbility fail, code: ${error.code}, message: ${error.message}`) + } finally { + this.visibilityChanging = false + } + } + + /** @description 恢复既有主窗口(不创建新窗口实例),并刷新托盘菜单状态 */ + async restoreMainWindow(): Promise { + if (this.visibilityChanging || this.exiting) { + return + } + this.visibilityChanging = true + try { + const context: common.UIAbilityContext | undefined = this.context + if (!context || !this.isActive()) { + hilog.error(0x0000, TAG, 'showAbility skipped: status bar attachment is not ready') + return + } + // 后台状态下 interval 可能被系统节流;先同步运行时长再显示窗口, + // 避免首帧显示隐藏前的旧值、下一拍突然追平。 + ClashViewModel.refreshProxyDuration() + await context.showAbility() + hilog.info(0x0000, TAG, 'showAbility success') + this.requestRebuildMenu() + } catch (err) { + const error = err as BusinessError + hilog.error(0x0000, TAG, `showAbility fail, code: ${error.code}, message: ${error.message}`) + } finally { + this.visibilityChanging = false + } + } + + /** + * @description 协调系统发起的应用退出:只执行同步、轻量且幂等的托盘清理。 + * 调用方随后返回 TERMINATE_IMMEDIATELY/false,由系统继续正常生命周期; + * 不在预终止回调内主动杀死当前进程。 + */ + exitApp(): void { + this.beginExit() + } + + /** + * @description 应用界面内的直接退出入口。保留既有 killAllProcesses 语义, + * 但与系统预终止路径分离,避免在生命周期回调返回前杀死当前进程。 + */ + forceExitApp(): void { + if (!this.beginExit()) { + return + } + if (this.context) { + this.context.getApplicationContext().killAllProcesses().catch((err: BusinessError) => { + hilog.error(0x0000, TAG, `killAllProcesses fail, code: ${err.code}, message: ${err.message}`) + }) + } else { + // 非桌面环境未缓存托盘上下文时沿用原有UI退出工具 + Xb_ToastUtil.exitApp() + } + } + + /** @description 原子地取得退出所有权,防止多个终止回调重复清理/退出。 */ + private beginExit(): boolean { + if (this.exiting) { + return false + } + this.exiting = true + hilog.info(0x0000, TAG, '开始协调应用退出') + this.destroy() + return true + } + + /** @description 注销监听并移除托盘图标。仅在应用真正退出时调用。 */ + destroy(): void { + this.holderAttached = false + if (!this.initialized) { + return + } + this.initialized = false + clearTimeout(this.rebuildTimer) + this.menuRefreshRequested = false + try { + statusBarManager.off('statusBarIconClick', this.onStatusBarIconClick) + statusBarManager.off('rightMenuClick', this.onRightMenuClick) + } catch (e) { + hilog.error(0x0000, TAG, `off statusBar event failed. message: ${e.message}`) + } + EventHub.off(EventKey.TrayMenuRefresh) + if (this.context) { + try { + // 无前台窗口时可能返回1010710004,此时进程退出后由系统清理图标 + statusBarManager.removeFromStatusBar(this.context) + hilog.info(0x0000, TAG, 'removeFromStatusBar success') + } catch (e) { + hilog.error(0x0000, TAG, `removeFromStatusBar failed. code: ${e.code}, message: ${e.message}`) + } + } + this.menuCodeMap.clear() + } + + /** @description 托盘图标左键点击 -> 恢复既有主窗口 */ + private onStatusBarIconClick = (eventData: emitter.EventData) => { + const data = eventData.data as Record + if (data) { + // 官方文档定义:仅返回 iconClickType,取值 leftClick(左键) + const iconClickType = data['iconClickType'] + if (iconClickType === 'leftClick') { + hilog.info(0x0000, TAG, `statusBarIconClick ${iconClickType} 恢复主窗口`) + this.restoreMainWindow().catch((err: BusinessError) => { + hilog.error(0x0000, TAG, `restoreMainWindow fail, code: ${err.code}, message: ${err.message}`) + }) + } + } + } + + /** @description 托盘图标右键菜单点击 -> 节点切换/打开主界面(退出走系统自带项) */ + private onRightMenuClick = (eventData: emitter.EventData) => { + const data = eventData.data as Record + if (!data) { + return + } + const menuCode = data['menuCode'] + hilog.info(0x0000, TAG, `rightMenuClick menuCode: ${menuCode}`) + switch (menuCode) { + case MENU_CODE_OPEN: + case MENU_CODE_MORE: + // 打开主界面(更多节点场景下进入代理页自行选择) + this.restoreMainWindow().catch((err: BusinessError) => { + hilog.error(0x0000, TAG, `restoreMainWindow fail, code: ${err.code}, message: ${err.message}`) + }) + break + default: { + const target = this.menuCodeMap.get(menuCode) + if (target) { + this.switchProxy(target[0], target[1]).catch((err: BusinessError) => { + hilog.error(0x0000, TAG, + `switchProxy fail, code: ${err.code}, message: ${err.message}`) + }) + } + break + } + } + } + + /** + * @description 快捷节点切换 - 与代理页使用同一 ClashViewModel 状态发布路径, + * 不创建独立的代理状态系统。 + */ + private async switchProxy(group: string, node: string): Promise { + try { + const appConfig = AppStorage.get('appConfig') + if (!appConfig?.currentProfileId) { + hilog.error(0x0000, TAG, `switchProxy 无当前配置,无法切换节点`) + return + } + const profile = await ClashViewModel.getProfile(appConfig.currentProfileId) + if (!profile) { + hilog.error(0x0000, TAG, `switchProxy 配置不存在,无法切换节点`) + return + } + // 只有内核确认切换成功后才更新卡片和主界面,避免本地假选中。 + const changed = await ClashViewModel.changeProxy(profile, group, node) + if (!changed) { + this.requestRebuildMenu() + return + } + + // 回读 Mihomo 运行时状态作为唯一真值,不用 Profile 缓存推测切换结果。 + const groups = await ClashViewModel.getProxyGroups( + AppStorage.get('clashConfig')?.mode ?? ProxyMode.Rule, true) + const actualGroup = groups.find((item: ProxyGroup) => item.name === group) + const actualNode = actualGroup?.now + if (!actualNode || actualNode !== node) { + hilog.error(0x0000, TAG, + `switchProxy 运行时校验失败, group: ${group}, requested: ${node}, actual: ${actualNode ?? ''}`) + // 如果内核回读与请求不一致,用真实值修复持久化选择。 + if (actualNode) { + await ClashViewModel.syncProxySelection(profile, group, actualNode) + appConfig.currentProxyItem = AppState.fetchProxyItem( + groups, group, actualNode) as ProxyItem + } + this.requestRebuildMenu() + return + } + // currentProxyName/Profile/卡片已由 changeProxy 的成功事件统一更新。 + appConfig.currentProxyItem = AppState.fetchProxyItem(groups, group, node) as ProxyItem + this.requestRebuildMenu() + } catch (e) { + hilog.error(0x0000, TAG, `switchProxy 切换节点失败: ${e.message}`) + this.requestRebuildMenu() + } + } + + /** @description 启动状态栏保活Ability(当前进程附着 + 隐藏启动) */ + private async startHolderAbility(): Promise { + const context: common.UIAbilityContext | undefined = this.context + if (!context) { + return false + } + const want: Want = { + bundleName: context.abilityInfo.bundleName, + abilityName: 'ClashTrayHolderAbility' + } + const options: StartOptions = { + // 当前进程附着到状态栏图标;TrayManager退出守卫可覆盖两个Ability回调。 + processMode: contextConstant.ProcessMode.ATTACH_TO_STATUS_BAR_ITEM, + // 目标Ability启动后隐藏,不显示窗口 + startupVisibility: contextConstant.StartupVisibility.STARTUP_HIDE + } + let started: boolean = false + await context.startAbility(want, options).then(() => { + started = true + hilog.info(0x0000, TAG, 'startHolderAbility success') + hilog.info(0x0000, 'TrayDiag', + 'holderAbility start=success (ATTACH_TO_STATUS_BAR_ITEM + STARTUP_HIDE)') + }).catch((err: BusinessError) => { + hilog.error(0x0000, TAG, `startHolderAbility fail, code: ${err.code}, message: ${err.message}`) + hilog.error(0x0000, 'TrayDiag', + `holderAbility start=failed code=${err.code ?? ''} message=${err.message}`) + }) + return started + } + + /** @description 完成一次状态栏注册;回调返回后系统条目才可用于进程附着。 */ + private addStatusBarItem(context: common.UIAbilityContext, + item: statusBarManager.StatusBarItem): Promise { + return new Promise((resolve, reject) => { + statusBarManager.addToStatusBar(context, item, (error: BusinessError) => { + // HarmonyOS 的 AsyncCallback 在成功时不传 BusinessError。 + // 不能直接读取 error.code,否则成功回调会抛异常并让此 Promise 永久 pending。 + if (error) { + reject(error) + return + } + hilog.info(0x0000, 'TrayDiag', 'addToStatusBar callback=success') + resolve() + }) + }) + } + + /** @description 防抖重建右键菜单 */ + private requestRebuildMenu(): void { + this.menuRefreshRequested = true + if (this.menuRefreshInProgress) { + return + } + clearTimeout(this.rebuildTimer) + this.rebuildTimer = setTimeout(() => { + this.rebuildTimer = -1 + this.refreshProxyMenu() + }, MENU_REBUILD_DEBOUNCE) + } + + /** @description 依据当前代理分组数据重建右键菜单并推送到托盘 */ + private async refreshProxyMenu(): Promise { + if (this.menuRefreshInProgress || !this.initialized || this.exiting || !this.context) { + return + } + this.menuRefreshInProgress = true + this.menuRefreshRequested = false + try { + const snapshot = await this.buildGroupMenus() + // 更新期同时保留新旧代码:旧菜单或新菜单的点击都不会被错配。 + const previousCodeMap = this.menuCodeMap + const transitionCodeMap = new Map(previousCodeMap) + snapshot.codeMap.forEach((value: [string, string], key: string) => { + transitionCodeMap.set(key, value) + }) + this.menuCodeMap = transitionCodeMap + try { + await this.updateStatusBarMenu(snapshot.groupMenus) + this.menuCodeMap = snapshot.codeMap + } catch (e) { + this.menuCodeMap = previousCodeMap + throw e as Error + } + hilog.info(0x0000, TAG, + `updateStatusBarMenu success, groups: ${snapshot.groupMenus.length}`) + } catch (e) { + // 代理数据暂不可用(Mihomo未就绪/配置加载中)时保留上一次菜单,不崩溃 + hilog.error(0x0000, TAG, `refreshProxyMenu failed. code: ${e.code}, message: ${e.message}`) + } finally { + this.menuRefreshInProgress = false + // 更新期又有代理状态变化时,防抖后再发布一份最新快照。 + if (this.menuRefreshRequested) { + this.requestRebuildMenu() + } + } + } + + /** + * @description 构建右键分组菜单: + * 分组1 - 可切换的代理分组(Selector类型),当前选中节点带 ✓ 前缀; + * 分组2 - 打开ClashBox / 退出。 + * 超长节点列表按平台限制截断,并提供"更多节点"入口。 + */ + private async buildGroupMenus(): Promise { + const clashConfig = AppStorage.get('clashConfig') + // 静默获取(不弹Toast),代理数据暂不可用时返回空列表 + const groups = await ClashViewModel.getProxyGroups(clashConfig?.mode ?? ProxyMode.Rule, true) + const selectable = groups.filter((g: ProxyGroup) => g.type === ProxyType.Selector && g.hidden !== true) + + const codeMap = new Map() + const generation = ++this.menuGeneration + const proxyMenu: statusBarManager.StatusBarMenuItem[] = [] + const maxGroups = Math.min(selectable.length, MAX_GROUP_MENU) + for (let i = 0; i < maxGroups; i++) { + const group = selectable[i] + const nodes = group.proxies ?? [] + // 空分组会产生"既无子菜单又无行为"的菜单项(1010720001),跳过 + if (nodes.length === 0) { + continue + } + // group.now 来自 Mihomo 运行时查询,才是当前真正生效的节点。 + const selected = group.now + const subMenus: statusBarManager.StatusBarSubMenuItem[] = [] + // 每个一级菜单最多 MAX_SUB_MENU 个子菜单项(平台限制) + const maxNodes = Math.min(nodes.length, MAX_SUB_MENU - 1) + for (let j = 0; j < maxNodes; j++) { + const node = nodes[j] + const menuCode = `clashbox.tray.n${generation}-${i}-${j}` + codeMap.set(menuCode, [group.name, node.name]) + const isSelected = node.name === selected + subMenus.push({ + subTitle: (isSelected ? '✓ ' : '') + node.name, + menuAction: this.notifyMenuAction(menuCode) + }) + } + if (nodes.length > MAX_SUB_MENU - 1) { + // 节点过多被截断:提供"更多节点"入口打开主界面 + subMenus.push({ + subTitle: this.getString($r('app.string.tray_more_nodes'), '更多节点…'), + menuAction: this.notifyMenuAction(MENU_CODE_MORE) + }) + } + proxyMenu.push({ title: group.name, subMenu: subMenus }) + } + + // 注意:系统会在托盘右键菜单自动提供"退出"项(经 AbilityStage.onPrepareTermination + // 真实终止应用),应用不再自建退出项,避免重复退出入口。 + const actionMenu: statusBarManager.StatusBarMenuItem[] = [ + { + title: this.getString($r('app.string.tray_open_app'), '打开ClashBox'), + menuAction: this.notifyMenuAction(MENU_CODE_OPEN) + } + ] + const groupMenus: statusBarManager.StatusBarGroupMenu[] = [] + if (proxyMenu.length > 0) { + groupMenus.push(proxyMenu) + } + groupMenus.push(actionMenu) + return { + groupMenus: groupMenus, + codeMap: codeMap + } + } + + /** @description 构建托盘保活 Ability 路由 + rightMenuClick 通知的菜单行为。 */ + private notifyMenuAction(menuCode: string): statusBarManager.StatusBarMenuAction { + return { + // 菜单动作会拉起 abilityName;路由到无窗口的托盘保活 Ability, + // 节点切换仅由 rightMenuClick 处理,不显示主界面。 + abilityName: MENU_ACTION_ABILITY, + moduleName: 'entry', + notifyOnly: true, + menuCode: menuCode + } + } + + /** @description 等待系统确认菜单快照已发布,再切换对应的 menuCode 映射。 */ + private updateStatusBarMenu(groupMenus: statusBarManager.StatusBarGroupMenu[]): Promise { + const context = this.context + if (!context) { + return Promise.reject(new Error('UIAbilityContext unavailable')) + } + return new Promise((resolve, reject) => { + statusBarManager.updateStatusBarMenu(context, groupMenus, (error: BusinessError) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } + + /** @description 加载托盘图标(黑/白两份)。优先使用专用状态栏图标,失败时回退到应用Logo */ + private async loadStatusBarIcon(context: common.UIAbilityContext): Promise { + try { + const white = await this.createPixelMap(context, 'clash_status_white.svg') + const black = await this.createPixelMap(context, 'clash_status_black.svg') + return { white: white, black: black } + } catch (e) { + hilog.error(0x0000, TAG, `加载状态栏专用图标失败,回退Logo: ${e.message}`) + const logo = await this.createPixelMap(context, 'box_cat_round.png') + return { white: logo, black: logo } + } + } + + /** @description 从rawfile解码PixelMap */ + private async createPixelMap(context: common.UIAbilityContext, name: string): Promise { + const fileData = context.resourceManager.getRawFileContentSync(name) + const imageSource = image.createImageSource(fileData.buffer) + return await imageSource.createPixelMap() + } + + /** @description 同步获取字符串资源(带兜底) */ + private getString(res: Resource, fallback: string): string { + try { + const value = this.context?.resourceManager.getStringSync(res) + return value ?? fallback + } catch (e) { + return fallback + } + } +} diff --git a/entry/src/main/ets/components/Common/TopBar.ets b/entry/src/main/ets/components/Common/TopBar.ets index 1003d911..9cb54528 100644 --- a/entry/src/main/ets/components/Common/TopBar.ets +++ b/entry/src/main/ets/components/Common/TopBar.ets @@ -6,7 +6,7 @@ import { ButtonBuilder, context, font_primary, icon_primary } from "./Common"; import { BusinessError } from "@kit.BasicServicesKit"; import { calculateFontSizeLinear } from "../../common/utils/CalculateFontSizeUtil"; import { common } from "@kit.AbilityKit"; -import { Xb_ToastUtil } from "xb_components"; +import { TrayManager } from "../../common/utils/TrayManager"; let theWindowClass: window.Window @@ -169,19 +169,17 @@ export struct WindowTopBar { { icon: $r('sys.symbol.xmark'), image: false, - onClick: () => { + onClick: async () => { if (this.uiConfig.exitWindowMode === 1) { - Xb_ToastUtil.exitApp() + // 应用内"直接退出"保留既有强制退出语义,但不复用系统预终止路径 + TrayManager.getInstance().forceExitApp() } else { - this.context.hideAbility().then(() => { - console.debug(`#WindowDecorButtonModel hideAbility success`); - }).catch((err: BusinessError) => { - console.error(`#WindowDecorButtonModel hideAbility fail, code: ${err.code} message: ${err.message}, stack: ${JSON.stringify(err.stack)}`); - }); + // 等待应用完成状态栏接入后隐藏主窗口;不退化成普通最小化。 + await TrayManager.getInstance().hideMainWindow(this.context) } } } ] } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/components/Home/FavoriteProxy.ets b/entry/src/main/ets/components/Home/FavoriteProxy.ets index 416f6697..445ff1fb 100644 --- a/entry/src/main/ets/components/Home/FavoriteProxy.ets +++ b/entry/src/main/ets/components/Home/FavoriteProxy.ets @@ -9,7 +9,6 @@ import { customAnimationUtil } from '../../common/utils/Animation' import ClashViewModel from '../../entryability/ClashViewModel' import { HomeCardDeleteButtonSize, HomeCardFontSize, HomeCardPadding, HomeCardPartSmallFontSize } from '../../common/breakpoint/BreakPoint' -import { cardManager } from '../../common/utils/CardManageUtil' @Component @@ -62,10 +61,10 @@ struct FavoriteProxy { .onClick(() => { // 在home页编辑状态时,无法操作 if (!this.isShowHomeEdit) { - this.appConfig.currentProxyName = group_node[1] - this.appConfig.currentProxyItem = AppState.fetchProxyItem(this.proxyGroups.getAllData(), group_node[0], group_node[1]) - // 对应切换代理页节点 - this.changeProxy(group_node[0], this.appConfig.currentProxyItem) + const proxyItem = AppState.fetchProxyItem( + this.proxyGroups.getAllData(), group_node[0], group_node[1]) + // 对应切换代理页节点;成功后由统一事件更新 UI 与卡片。 + this.changeProxy(group_node[0], proxyItem) if (this.uiConfig.isVibrate) { customVibrator.vibratorTriggerOfHapticClockTimer() } @@ -137,8 +136,6 @@ struct FavoriteProxy { console.debug(`FavoriteProxy [FavoriteProxyCard] Group:${g} node: ${item.name}`) if (this.currentProfile) { ClashViewModel.changeProxy(this.currentProfile, g, item.name) - // 首选项持久化存储 -> 供卡片使用 - cardManager.setSelectedProxyNode(this.currentProfile, g, item.name) } } @@ -149,4 +146,4 @@ struct FavoriteProxy { } } -export default FavoriteProxy \ No newline at end of file +export default FavoriteProxy diff --git a/entry/src/main/ets/components/Proxy/ProxyArrangement.ets b/entry/src/main/ets/components/Proxy/ProxyArrangement.ets index d2445ccc..42b3351a 100644 --- a/entry/src/main/ets/components/Proxy/ProxyArrangement.ets +++ b/entry/src/main/ets/components/Proxy/ProxyArrangement.ets @@ -6,7 +6,6 @@ import { ProxyItem, ProxyGroupItemDataSource } from '../../common/datasources/Pr import ClashViewModel from '../../entryability/ClashViewModel' import { AppConfig, UIConfig } from '../../entryability/AppState' import { customAnimationUtil } from '../../common/utils/Animation' -import { cardManager } from '../../common/utils/CardManageUtil' const componentName: string = 'ProxyPageArrangement' @@ -135,17 +134,15 @@ struct ProxyArrangement { changeProxy(g: string, item: ProxyItem){ if (this.currentProfile) { ClashViewModel.changeProxy(this.currentProfile, g, item.name) - // 首选项持久化存储 -> 供卡片使用 - cardManager.setSelectedProxyNode(this.currentProfile, g, item.name) } } /** 获取当前分组名 */ getCurrentProxyName(group: ProxyGroup): string { if (!this.currentProfile) { - return '' + return group.now } else { - return this.currentProfile.getSelectedProxy(group) ?? this.appConfig.currentProxyName ?? '' + return group.now || this.currentProfile.getSelectedProxy(group) || this.appConfig.currentProxyName || '' } } @@ -175,4 +172,3 @@ struct ProxyArrangement { export default ProxyArrangement - diff --git a/entry/src/main/ets/components/Proxy/ProxyGroupItem.ets b/entry/src/main/ets/components/Proxy/ProxyGroupItem.ets index 1ec0d837..0b8e3cf6 100644 --- a/entry/src/main/ets/components/Proxy/ProxyGroupItem.ets +++ b/entry/src/main/ets/components/Proxy/ProxyGroupItem.ets @@ -15,7 +15,6 @@ import { customVibrator } from '../../common/utils/VibratorUtil'; import ClashViewModel from '../../entryability/ClashViewModel'; import { PopTips, PopupBuilder } from '../Start/Popup'; import { ListGutter, ProxyListLanes } from '../../common/breakpoint/BreakPoint'; -import { cardManager } from '../../common/utils/CardManageUtil'; import { Xb_ToastUtil } from 'xb_components'; // 组件名 @@ -361,9 +360,7 @@ struct ProxyGroupItem { selectProxy(item: ProxyItem) { if (!this.disabled) { - this.appConfig.currentProxyName = item.name - cardManager.pushCartCurrentProxyName(item.name) - this.appConfig.currentProxyItem = item + // 选中态由内核成功事件统一发布,避免 RPC 尚未完成时显示假选中。 this.OnProxyChange(item) } } @@ -494,4 +491,4 @@ function ListItemStyle() { .backgroundColor($r('app.color.container_background')) } -export default ProxyGroupItem \ No newline at end of file +export default ProxyGroupItem diff --git a/entry/src/main/ets/entryability/ClashAbilityStage.ets b/entry/src/main/ets/entryability/ClashAbilityStage.ets new file mode 100644 index 00000000..4bed8832 --- /dev/null +++ b/entry/src/main/ets/entryability/ClashAbilityStage.ets @@ -0,0 +1,32 @@ +import { AbilityConstant, AbilityStage } from '@kit.AbilityKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { TrayManager } from '../common/utils/TrayManager'; + +const TAG = 'ClashAbilityStage' + +/** + * @description 应用级 AbilityStage: + * 实现 onPrepareTermination 以区分"系统托盘/任务栏发起的应用退出" + * 与"UIAbility 窗口关闭(关闭到托盘)"。 + * 依据 SDK 语义:实现本回调后,用户在托盘或任务栏(Dock)右键关闭应用时, + * 不再回调 UIAbility.onPrepareToTerminate,而是走本回调 —— + * 因此系统托盘自带的"退出"项在此真实终止应用,不会被窗口关闭拦截吞掉。 + * 窗口 X 关闭仍由 EntryAbility.onPrepareToTerminate 隐藏到托盘。 + */ +export default class ClashAbilityStage extends AbilityStage { + + onCreate(): void { + hilog.info(0x0000, TAG, 'onCreate') + } + + /** + * @description 用户从系统托盘/任务栏关闭应用(应用级退出)。 + * 仅标记退出并同步清理托盘资源,然后放行系统正常终止; + * 不在预终止回调内主动杀死当前进程。 + */ + onPrepareTermination(): AbilityConstant.PrepareTermination { + hilog.info(0x0000, TAG, 'onPrepareTermination 系统托盘/任务栏发起应用退出') + TrayManager.getInstance().exitApp() + return AbilityConstant.PrepareTermination.TERMINATE_IMMEDIATELY + } +} diff --git a/entry/src/main/ets/entryability/ClashTrayHolderAbility.ets b/entry/src/main/ets/entryability/ClashTrayHolderAbility.ets new file mode 100644 index 00000000..08631a38 --- /dev/null +++ b/entry/src/main/ets/entryability/ClashTrayHolderAbility.ets @@ -0,0 +1,47 @@ +import { UIAbility } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { TrayManager } from '../common/utils/TrayManager'; + +const TAG = 'ClashTrayHolderAbility' + +/** + * @description 托盘保活Ability:以 ATTACH_TO_STATUS_BAR_ITEM + STARTUP_HIDE 模式启动, + * 将应用进程附着到状态栏图标(托盘对用户可见、可退出,进程可后台保活), + * 同时满足 showAbility/hideAbility 的前置条件。 + * 本Ability无窗口、无页面,仅承载生命周期与终止拦截。 + */ +export default class ClashTrayHolderAbility extends UIAbility { + + onCreate(): void { + hilog.info(0x0000, TAG, 'onCreate 托盘保活Ability已启动(隐藏模式)') + } + + /** + * 右键菜单的 StatusBarMenuAction 必须路由到一个 UIAbility。系统若将本保活 + * Ability 切到前台,立即恢复隐藏;真正的节点操作由 TrayManager.rightMenuClick 执行。 + */ + onForeground(): void { + this.context.hideAbility().catch((err: BusinessError) => { + hilog.error(0x0000, TAG, + `hide tray action ability fail, code: ${err.code}, message: ${err.message}`) + }) + } + + /** + * @description 系统托盘"退出"终止的是附着到托盘图标的保活Ability。 + * 此处只认领并清理一次退出状态,然后返回false让系统继续正常终止; + * TrayManager的幂等守卫避免与AbilityStage回调重复清理。 + * 注:实现 AbilityStage.onPrepareTermination 后,托盘/任务栏关闭 + * 优先走 AbilityStage;本回调作为其未生效时的兜底路径。 + */ + onPrepareToTerminate(): boolean { + hilog.info(0x0000, TAG, 'onPrepareToTerminate 系统托盘退出 -> 显式退出应用') + TrayManager.getInstance().exitApp() + return false + } + + onDestroy(): void { + hilog.info(0x0000, TAG, 'onDestroy') + } +} diff --git a/entry/src/main/ets/entryability/ClashViewModel.ets b/entry/src/main/ets/entryability/ClashViewModel.ets index 2ab0dbd0..16052c87 100644 --- a/entry/src/main/ets/entryability/ClashViewModel.ets +++ b/entry/src/main/ets/entryability/ClashViewModel.ets @@ -11,7 +11,7 @@ import { convertTimeGapToMilliseconds } from "../common/entity/Constants"; import { PromptAction } from "@kit.ArkUI"; import { ProxyGroup } from "proxy_core/src/main/ets/models/Common"; import { ConfigExtendedParams } from "proxy_core/src/main/ets/models/ClashConfig"; -import { EventHub, EventKey } from "../common/EventHub"; +import { EventHub, EventKey, ProxySelectionChangedData } from "../common/EventHub"; import { AppConfig, AppState, ClashCore, getPackageInfo } from "./AppState"; import fs from "@ohos.file.fs" import { sleep } from "./EntryAbility"; @@ -26,6 +26,7 @@ import { number2Time } from "../common/utils/TimeConvertUtil"; import { picker } from "@kit.CoreFileKit" import { Xb_GetResourceString, Xb_ToastUtil } from "xb_components"; import { accessControlRdb } from "../common/datasources/AccessControlRdb"; +import ProxyKeepAliveService from "../common/utils/ProxyKeepAliveService"; export interface DelayInfo{ name: string @@ -199,14 +200,57 @@ export class ClashViewModel { async VailConfig(config: string): Promise{ return await this.socketProxy.vailConfig(config) } - async changeProxy(profile: Profile, g:string, p: string){ + async changeProxy(profile: Profile, g:string, p: string): Promise { try { - profile.proxySelected?.set(g, p) - await this.profileRepo.addOrUpdate(profile) - await this.socketProxy.changeProxy(g, p) + // 内核以空字符串表示成功,非空字符串是业务错误,不会通过 Promise reject。 + const result = await this.socketProxy.changeProxy(g, p) + if (result !== '') { + throw new Error(result) + } + + // 只在内核确认切换后发布同一份选择结果,避免各入口各自维护假状态。 + await this.syncProxySelection(profile, g, p) EventHub.sendEvent(EventKey.checkIpInfo) + return true } catch (e) { - ClashViewModel.promptAction.showToast({message: e.message, duration:3000}) + ClashViewModel.promptAction?.showToast({message: e.message, duration:3000}) + // 失败时也回读运行时状态,用实际选择修正菜单。 + EventHub.sendEvent(EventKey.TrayMenuRefresh) + return false + } + } + + /** + * 将内核已经确认的节点选择同步到 Profile、AppStorage、卡片和界面事件。 + * TrayManager 回读到不同运行时结果时也使用此入口进行纠正。 + */ + async syncProxySelection(profile: Profile, group: string, proxy: string): Promise { + profile.proxySelected.set(group, proxy) + const appConfig = AppStorage.get('appConfig') + if (appConfig) { + appConfig.currentProxyName = proxy + } + + try { + cardManager.setSelectedProxyNode(profile, group, proxy) + cardManager.pushCartCurrentProxyName(proxy) + } catch (cardError) { + hilog.error(0x0000, 'ClashViewModel', + `syncProxySelection card failed, group: ${group}, proxy: ${proxy}, message: ${cardError.message}`) + } + + EventHub.sendEvent(EventKey.ProxySelectionChanged, { + group: group, + proxy: proxy + } as ProxySelectionChangedData) + EventHub.sendEvent(EventKey.TrayMenuRefresh) + + try { + await this.profileRepo.addOrUpdate(profile) + } catch (persistError) { + // 内核已切换成功,持久化失败不应将本次运行时操作报为失败。 + hilog.error(0x0000, 'ClashViewModel', + `syncProxySelection persist failed, group: ${group}, proxy: ${proxy}, message: ${persistError.message}`) } } async setVpnOptions(options: VpnRawOptions){ @@ -319,7 +363,7 @@ export class ClashViewModel { async clearRequestList(){ return await this.socketProxy.clearRequestList() } - async getProxyGroups(model: ProxyMode): Promise{ + async getProxyGroups(model: ProxyMode, silent: boolean = false): Promise{ try { let list = await this.socketProxy.queryProxyGroups(model) let favoriteProxys = AppStorage.get>("favoriteProxys") @@ -335,7 +379,9 @@ export class ClashViewModel { return groups } catch (e) { console.error(" 获取代理失败: ", e.stack) - ClashViewModel.promptAction.showToast({message: "获取代理失败: " + (e.message || e) + e.stack}) + if (!silent) { + ClashViewModel.promptAction.showToast({message: "获取代理失败: " + (e.message || e) + e.stack}) + } } return [] } @@ -407,6 +453,8 @@ export class ClashViewModel { ClashViewModel.promptAction.showToast({message: result, duration: 3000 }) } EventHub.sendEvent(EventKey.FetchProxyGroup, null) + // 通知托盘刷新右键菜单(配置加载/重载后分组可能变化) + EventHub.sendEvent(EventKey.TrayMenuRefresh, null) } async ReStartVpn(){ if(this.vpnStarted){ @@ -414,6 +462,10 @@ export class ClashViewModel { await this.loadVpnOptions() await this.socketProxy.stopClash() await this.socketProxy.startClash() + // 内核恢复后重新确认长时任务保活(幂等) + if (this.context) { + ProxyKeepAliveService.start(this.context) + } } } vpnStarted = false @@ -449,6 +501,12 @@ export class ClashViewModel { }) cardManager.pushCartProxyMode(this.vpnStarted) + + // 自动申请长时任务保活:机器锁定/息屏后防止 UI 进程被系统回收导致代理中断 + // (Codex 等依赖代理的应用无法连接)。与用户手动开启的保活开关互斥。 + if (this.context) { + ProxyKeepAliveService.start(this.context) + } } async loadVpnOptions(){ // 使用首选项持久化单个节点信息供卡片拉起服务 @@ -501,6 +559,10 @@ export class ClashViewModel { async StopVpn(){ this.vpnStarted = false this.socketProxy.stopClash() + // 代理停止后释放自动长时任务保活(仅释放本服务申请的任务) + if (this.context) { + ProxyKeepAliveService.stop(this.context) + } // Index里的停止后操作 EventHub.sendEvent(EventKey.StopedClash) // EntryAbility里的停止后操作 @@ -513,6 +575,11 @@ export class ClashViewModel { async getRuntime(){ return await this.socketProxy.getRuntime() } + + /** @description 立即同步一次代理运行时长;用于窗口从状态栏恢复前更新UI。 */ + refreshProxyDuration(): void { + this.proxyStartedTimer.refresh() + } // 校验是否配置成功 async vpnConfigIsLoad(){ const flag = await this.socketProxy.vpnConfigIsLoad() diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 1b49bb64..1e30d1bd 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -20,18 +20,18 @@ import { commonEventManager } from '@kit.BasicServicesKit'; import { Timer } from '../common/utils/HHmmssTimer'; import { number2Time } from '../common/utils/TimeConvertUtil'; import { customVibrator } from '../common/utils/VibratorUtil'; -import { StatusBarConfig, StatusBarIconResource, - SmartAvoidance, +import { SmartAvoidance, Xb_AvoidanceManager, Xb_ColorUtils, Xb_Global, Xb_KVdbUtil, - Xb_StatusBarUtils, Xb_ToastUtil, Xb_PreferenceUtil} from 'xb_components'; import BackupRestoreUtil from '../common/utils/BackupRestoreUtil'; import ConfigAutoUpdateService from '../common/utils/ConfigAutoUpdateService'; import BuildProfile from 'BuildProfile'; import { accessControlRdb } from '../common/datasources/AccessControlRdb'; +import { TrayManager } from '../common/utils/TrayManager'; +import ProxyKeepAliveService from '../common/utils/ProxyKeepAliveService'; export default class EntryAbility extends UIAbility { @@ -57,6 +57,7 @@ export default class EntryAbility extends UIAbility { /** @description 卡片延迟定时器 */ private cardDelayTimer: number = -1 private subscriber: commonEventManager.CommonEventSubscriber | null = null; + private screenSubscriber: commonEventManager.CommonEventSubscriber | null = null; private windowLimits: window.WindowLimits = { minWidth: 1345, minHeight: 498, @@ -165,12 +166,58 @@ export default class EntryAbility extends UIAbility { } }); + // 监听锁屏/解锁事件:锁定前后重新确认长时任务保活 + this.registerScreenStateEvents(); + } // 实时监听系统系统配置更新状态 onConfigurationUpdate(Config: Configuration): void { } + /** + * @description 监听锁屏/息屏与解锁/亮屏事件。 + * 机器锁定后系统可能冻结/回收后台应用进程并撤销长时任务, + * 在锁定前后重新确认 ProxyKeepAliveService 的长时任务保活; + * 代理内核自身的健康检查由 ClashCoreInit 的 1s 恢复循环负责。 + * 订阅失败仅记录日志,不影响主流程。 + */ + private registerScreenStateEvents(): void { + const screenSubscribeInfo: commonEventManager.CommonEventSubscribeInfo = { + events: [ + "usual.event.SCREEN_OFF", + "usual.event.SCREEN_LOCKED", + "usual.event.SCREEN_ON", + "usual.event.SCREEN_UNLOCKED" + ] + }; + commonEventManager.createSubscriber(screenSubscribeInfo, + (err: BusinessError, subscriber: commonEventManager.CommonEventSubscriber) => { + if (err) { + hilog.error(0x0000, 'EntryAbility', + `Failed to create screen event subscriber. Code is ${err.code}, message is ${err.message}`) + return + } + this.screenSubscriber = subscriber; + commonEventManager.subscribe(subscriber, + (subErr: BusinessError, data: commonEventManager.CommonEventData) => { + if (subErr) { + hilog.error(0x0000, 'EntryAbility', + `Failed to subscribe screen events. Code is ${subErr.code}, message is ${subErr.message}`) + return + } + const event = data?.event ?? '' + if (event === "usual.event.SCREEN_OFF" || event === "usual.event.SCREEN_LOCKED") { + hilog.info(0x0000, 'EntryAbility', `屏幕锁定/息屏事件: ${event},重新确认长时任务保活`) + ProxyKeepAliveService.reassert(this.context) + } else if (event === "usual.event.SCREEN_ON" || event === "usual.event.SCREEN_UNLOCKED") { + hilog.info(0x0000, 'EntryAbility', `屏幕解锁/亮屏事件: ${event},重新确认长时任务保活`) + ProxyKeepAliveService.reassert(this.context) + } + }) + }) + } + // 热启动 onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam) { this.getShareData(want, true); @@ -251,8 +298,13 @@ export default class EntryAbility extends UIAbility { } // 初始化通知工具 Xb_ToastUtil.initContext(this.uiContext, data.getUIContext().getHostContext() as common.UIAbilityContext) - // 初始化窗口避让属性 - this.intRectWindow(windowStage) + // 窗口和状态栏初始化不能阻塞配置数据库、核心及页面断点初始化。 + // intRectWindow 会在首次 await 前同步缓存 WindowClass;其余工作异步继续。 + this.intRectWindow(windowStage).catch((err: BusinessError) => { + hilog.error(0x0000, 'EntryAbility', + `intRectWindow fail, code: ${err.code}, message: ${err.message}`) + }) + this.initTray() // 初始化配置自动更新服务 ConfigAutoUpdateService.initContext(this.context) @@ -283,13 +335,14 @@ export default class EntryAbility extends UIAbility { // 通过主窗口UIContext创建typeNode节点 PipManager.getInstance().makeTypeNode(this.uiContext) + // 核心初始化后再幂等补试一次;初次窗口模式尚未就绪时, + // 可在此时完成状态栏接入,且不会重复注册图标或监听。 + this.initTray() + } catch (err) { console.error(`Failed to obtain the main window. Cause code: ${err.code}, message: ${err.message}`); } - // 执行首次窗口检测 - // this.onWindowModeChange(windowStage, windowClass) - // 初始化完成标记 AppStorage.setOrCreate('awaitInit', true) }); @@ -303,6 +356,27 @@ export default class EntryAbility extends UIAbility { } + /** + * @description UIAbility 窗口级终止(如最近任务关闭窗口)前回调。 + * 桌面托盘激活期间拦截终止:隐藏主窗口到托盘,保持应用与VPN运行; + * 显式退出(TrayManager.exitApp)时不拦截。 + * 注意:系统托盘/任务栏右键关闭走 AbilityStage.onPrepareTermination + * (应用级退出),不经过本回调 —— 两条路径职责分离: + * 窗口关闭 -> 隐藏到托盘;托盘退出 -> 真实终止应用。 + */ + onPrepareToTerminate(): boolean { + const tray = TrayManager.getInstance() + if (tray.isActive()) { + hilog.info(0x0000, 'EntryAbility', 'onPrepareToTerminate 拦截终止,隐藏到托盘') + tray.hideMainWindow(this.context).catch((err: BusinessError) => { + hilog.error(0x0000, 'EntryAbility', + `hideMainWindow fail, code: ${err.code}, message: ${err.message}`) + }) + return true + } + return false + } + onWindowStageWillDestroy(windowStage: window.WindowStage): void { // Main window will destroy, release UI related resources // 销毁避让管理器并移除监听 @@ -321,6 +395,8 @@ export default class EntryAbility extends UIAbility { onForeground(): void { // Ability has brought to foreground AppStorage.setOrCreate('ForwardState', true) + // 覆盖从状态栏、Dock或应用图标恢复的入口;计时器未运行时该调用为 no-op。 + ClashViewModel.refreshProxyDuration() hilog.info(0x0000, 'EntryAbility', '%{public}s', 'Ability onForeground'); // 应用切回前台时,触发一次配置更新检查 @@ -356,6 +432,8 @@ export default class EntryAbility extends UIAbility { onDestroy(): void { hilog.info(0x0000, 'EntryAbility', '%{public}s', 'Ability onDestroy'); + // 应用真正退出时注销托盘监听并移除托盘图标(隐藏窗口不会走到这里) + TrayManager.getInstance().destroy() this.saveConfigThePreferences() // 移除卡片监听事件 this.unRegisterCardEvent() @@ -532,7 +610,11 @@ export default class EntryAbility extends UIAbility { // 启动/停止代理 if (proxyNodeInfo?.profile) { - ClashViewModel.changeProxy(proxyNodeInfo.profile, proxyNodeInfo.g, proxyNodeInfo.p) + const proxyChanged = await ClashViewModel.changeProxy( + proxyNodeInfo.profile, proxyNodeInfo.g, proxyNodeInfo.p) + if (!proxyChanged) { + return + } console.log('卡片 - 启动/停止代理', proxyActionType) @@ -655,7 +737,10 @@ export default class EntryAbility extends UIAbility { try { windowClass.on('windowSizeChange', async (data) => { // 动态监听设备窗口模式 - this.onWindowModeChange(windowStage, windowClass) + await this.onWindowModeChange(windowStage, windowClass) + // 平板从普通模式切入自由窗口/PC模式时补做托盘初始化; + // TrayManager 内部的 initialization/initialized 守卫保证不会重复注册。 + this.initTray() console.info('#WindowChange Succeeded in enabling the listener for window size changes. Data: ' + JSON.stringify(data)) WindowWidth = this.uiContext?.px2vp(data.width)! @@ -673,6 +758,15 @@ export default class EntryAbility extends UIAbility { } } + /** @description 幂等初始化桌面托盘,并统一记录异步失败。 */ + private initTray(): void { + TrayManager.getInstance().init(this.context).catch((err: BusinessError) => { + const error = err as BusinessError + hilog.error(0x0000, 'EntryAbility', + `TrayManager.init fail, code: ${error.code}, message: ${error.message}`) + }) + } + async onWindowModeChange(windowStage: window.WindowStage, windowClass: window.Window) { try { // 设置默认标题栏是否显示 @@ -749,4 +843,4 @@ export function sleep(timeout: number): Promise { resolve() }, timeout) }) -} \ No newline at end of file +} diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 403103d0..67be853e 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -26,7 +26,7 @@ import { } from '../common/entity/Constants' import { ProxyItem, ProxyData, ProxyGroupItemDataSource, ProxyItemDataSource } from '../common/datasources/ProxyData' import { ClashConfig, IpInfo, Profile, ProxyMode } from 'proxy_core' -import { EventHub, EventKey } from '../common/EventHub' +import { EventHub, EventKey, ProxySelectionChangedData } from '../common/EventHub' import ClashViewModel from '../entryability/ClashViewModel' import { AppConfig, AppFlowingState, AppState, getPackageInfo, UIConfig } from '../entryability/AppState' import { common } from '@kit.AbilityKit' @@ -65,8 +65,6 @@ import { WindowTopBar } from '../components/Common/TopBar' import { ImportConfigFromURL } from '../components/Configuration/ImportConfigFromURL' import { Xb_ChangeThemeColor } from 'xb_components/src/main/ets/utils/ChangeThemeColorUtil' import { - StatusBarConfig, - StatusBarIconResource, Xb_BackgroundRunning, Xb_ColorModeManager, Xb_FloatTabBarAlignCenter, @@ -78,7 +76,6 @@ import { Xb_NotificationUtils, Xb_PreferenceUtil, Xb_ResourceColorUtil, - Xb_StatusBarUtils, Xb_TabBarVertical, Xb_TabIconFontSize, Xb_TabTitleFontSize @@ -205,6 +202,9 @@ struct Index { @Provide theRestoreTime: number = 0 // 通知更新任务id @State updateNoticeJob: number = 0 + /** 防止较早发起的运行时查询覆盖刚收到的节点切换事件。 */ + private proxyRefreshGeneration: number = 0 + private proxySelectionRevision: number = 0 // 暂停时刻 @Provide thePauseTime: number = 0 // 流量任务id @@ -766,30 +766,6 @@ struct Index { hilog.info(0x1000, componentName, `#aboutToAppear executed`) this.registerEvent() - // 初始化状态栏工具(仅PC设备) - if (this.windowMode) { - const iconResource: StatusBarIconResource = { - white: 'clash_status_white.svg', - black: 'clash_status_black.svg' - }; - const statusBarConfig: StatusBarConfig = { - icon: iconResource, - title: "entry", // 当前模块的的model-name - abilityName: '' // 留空,接管并使用默认状态栏图标点击事件 - // menus: [[{ - // title: "", - // menuAction: { abilityName: "" } - // }]] - }; - Xb_StatusBarUtils.initStatusBar(this.UIContext, statusBarConfig) - .then(() => { - Xb_StatusBarUtils.setIconClickCallback() - }) - .catch((err: BusinessError) => { - console.error(`Index #aboutToAppear StatusBarUtils.initStatusBar 状态栏图标初始化失败, Message: ${err.message}`); - }) - } - // 首次启动启用通知 if (!this.uiConfig.isRequestNotification) { EventHub.sendEvent(EventKey.EnabledNotice, true) @@ -1081,21 +1057,28 @@ struct Index { }) }) // 获取代理分组事件 - EventHub.on(EventKey.FetchProxyGroup, async () => { - if (this.appConfig.currentProfileId) { - this.currentProfile = await ClashViewModel.getProfile(this.appConfig.currentProfileId) - if (this.currentProfile) { - // 写入数据 - this.proxyGroups.empty() - const rawProxyGroups = await ClashViewModel.getProxyGroups(this.clashConfig.mode ?? ProxyMode.Rule) - const ProxyGroups = rawProxyGroups.filter(item => item.hidden !== true) - // hilog.info(0xB000, componentName, `ProxySort #Index 节点数据:${JSON.stringify(rawProxyGroups[0].proxies[0])}`) - this.proxyGroups.pushData(ProxyGroups) - this.proxyGroups.refresh() - } - } else { - this.proxyGroups = new ProxyGroupItemDataSource([]) + EventHub.on(EventKey.FetchProxyGroup, () => { + this.refreshProxyGroups() + }) + // 节点可从主界面、收藏卡片或状态栏菜单切换;统一更新页面持有的快照。 + EventHub.on(EventKey.ProxySelectionChanged, (selection: ProxySelectionChangedData) => { + this.proxySelectionRevision++ + const groups = this.proxyGroups.getAllData() + const selectedGroup = groups.find(group => group.name === selection.group) + if (selectedGroup) { + selectedGroup.now = selection.proxy + } + if (this.currentProfile) { + this.currentProfile.proxySelected.set(selection.group, selection.proxy) + } + + this.appConfig.currentProxyName = selection.proxy + const selectedItem = AppState.fetchProxyItem(groups, selection.group, selection.proxy) + if (selectedItem) { + this.appConfig.currentProxyItem = selectedItem } + // Map/ProxyGroup 的原地修改不会自动触发 ArkUI 重绘,显式刷新数据源。 + this.proxyGroups.refresh() }) // 清理日志事件 EventHub.on(EventKey.ClearLog, () => { @@ -1168,6 +1151,46 @@ struct Index { }) } + /** 从 Profile 数据库和 Mihomo 运行时重新获取页面快照。 */ + private async refreshProxyGroups(): Promise { + const refreshGeneration = ++this.proxyRefreshGeneration + const selectionRevision = this.proxySelectionRevision + try { + if (!this.appConfig.currentProfileId) { + this.currentProfile = null + this.proxyGroups = new ProxyGroupItemDataSource([]) + return + } + + const profile = await ClashViewModel.getProfile(this.appConfig.currentProfileId) + if (!profile) { + if (refreshGeneration !== this.proxyRefreshGeneration || + selectionRevision !== this.proxySelectionRevision) { + return + } + this.currentProfile = null + this.proxyGroups = new ProxyGroupItemDataSource([]) + return + } + + const rawProxyGroups = await ClashViewModel.getProxyGroups( + this.clashConfig.mode ?? ProxyMode.Rule) + if (refreshGeneration !== this.proxyRefreshGeneration || + selectionRevision !== this.proxySelectionRevision) { + return + } + + const proxyGroups = rawProxyGroups.filter(item => item.hidden !== true) + this.currentProfile = profile + this.proxyGroups.empty() + this.proxyGroups.pushData(proxyGroups) + this.proxyGroups.refresh() + } catch (error) { + hilog.error(0x0000, componentName, + `refreshProxyGroups failed: ${error.message}`) + } + } + aboutToDisappear() { hilog.info(0x1000, componentName, `#aboutToDisappear executed`) // 统一注销根目录事件 @@ -1193,6 +1216,7 @@ struct Index { /** @description 统一取消监听事件 */ unRegisterEvent() { EventHub.off(EventKey.ChangeProxy) + EventHub.off(EventKey.ProxySelectionChanged) EventHub.off(EventKey.FetchProxyGroup) EventHub.off(EventKey.ShareBundle) EventHub.off(EventKey.EnabledNotice) @@ -1368,6 +1392,8 @@ struct Index { hilog.info(0xFF00, "TimertestTag", "恢复的时刻: %{public}d", this.theRestoreTime) } this.proxyEnabled = ClashViewModel.vpnStarted + // 状态栏菜单可能在主页面未订阅事件时切换节点,回到前台时以运行时状态兜底校准。 + this.refreshProxyGroups() console.log(`HomeCard 前后台状态:${this.isForward}`) this.ShowPipChange() this.initLanguage() diff --git a/entry/src/main/ets/pages/ProxyPage.ets b/entry/src/main/ets/pages/ProxyPage.ets index 4799790b..ab7c81fe 100644 --- a/entry/src/main/ets/pages/ProxyPage.ets +++ b/entry/src/main/ets/pages/ProxyPage.ets @@ -207,8 +207,9 @@ struct ProxyPage { proxyitems: group.proxies as ProxyItem[] ?? [], ProxyGroupItemSelected: this.appConfig.proxyCardSize, disabled: group.type !== ProxyType.Selector, - selectedProxy: this.currentProfile?.getSelectedProxy(group) ?? - this.appConfig.currentProxyName ?? '', + // Mihomo 运行时结果优先;Profile 仅作为核心尚未返回状态时的回退。 + selectedProxy: group.now || this.currentProfile?.getSelectedProxy(group) || + this.appConfig.currentProxyName || '', OnProxyChange: (item) => { this.changeProxy(group.name, item) } @@ -555,8 +556,6 @@ struct ProxyPage { console.debug(`FavoriteProxy [ProxyPage] Group:${group} node: ${item.name}`) if (this.currentProfile) { ClashViewModel.changeProxy(this.currentProfile, group, item.name) - // 首选项持久化存储 -> 供卡片使用 - cardManager.setSelectedProxyNode(this.currentProfile, group, item.name) } } diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index cedc07f1..8532e449 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -4,6 +4,7 @@ "type": "entry", "description": "$string:module_desc", "mainElement": "EntryAbility", + "srcEntry": "./ets/entryability/ClashAbilityStage.ets", "deviceTypes": [ "phone", "tablet", @@ -79,12 +80,28 @@ "domainVerify": true } ] + }, + { + // 托盘保活Ability:以 ATTACH_TO_STATUS_BAR_ITEM + STARTUP_HIDE 模式启动, + // 将进程附着到状态栏图标(托盘可见/可退出),并满足 showAbility/hideAbility 前置条件 + "name": "ClashTrayHolderAbility", + "srcEntry": "./ets/entryability/ClashTrayHolderAbility.ets", + "description": "$string:EntryAbility_desc", + "icon": "$media:layered_image", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:foreground_box_cat_large", + "startWindowBackground": "$color:start_window_background", + "exported": true } ], "requestPermissions": [ { "name": "ohos.permission.INTERNET", }, + { + // 托盘保活/关闭拦截:onPrepareToTerminate 前置权限(系统授予) + "name": "ohos.permission.PREPARE_APP_TERMINATE" + }, { // 适人握持(动作感知) "name": "ohos.permission.DETECT_GESTURE" diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index c1e30afb..37d2b452 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -2088,6 +2088,14 @@ "name": "exit", "value": "退出" }, + { + "name": "tray_open_app", + "value": "打开ClashBox" + }, + { + "name": "tray_more_nodes", + "value": "更多节点…" + }, { "name": "app_already_exist", "value": "应用已存在" diff --git a/entry/src/main/resources/en_US/element/string.json b/entry/src/main/resources/en_US/element/string.json index b3c46423..0d119d67 100644 --- a/entry/src/main/resources/en_US/element/string.json +++ b/entry/src/main/resources/en_US/element/string.json @@ -2076,6 +2076,14 @@ "name": "exit", "value": "Exit" }, + { + "name": "tray_open_app", + "value": "Open ClashBox" + }, + { + "name": "tray_more_nodes", + "value": "More nodes…" + }, { "name": "app_already_exist", "value": "The application already exists" diff --git a/entry/src/main/resources/zh_HK/element/string.json b/entry/src/main/resources/zh_HK/element/string.json index e1899dff..29f5840c 100644 --- a/entry/src/main/resources/zh_HK/element/string.json +++ b/entry/src/main/resources/zh_HK/element/string.json @@ -2076,6 +2076,14 @@ "name": "exit", "value": "離開" }, + { + "name": "tray_open_app", + "value": "開啟ClashBox" + }, + { + "name": "tray_more_nodes", + "value": "更多節點…" + }, { "name": "app_already_exist", "value": "應用已存在" diff --git a/entry/src/main/resources/zh_TW/element/string.json b/entry/src/main/resources/zh_TW/element/string.json index ba7c046d..fc598d6c 100644 --- a/entry/src/main/resources/zh_TW/element/string.json +++ b/entry/src/main/resources/zh_TW/element/string.json @@ -2076,6 +2076,14 @@ "name": "exit", "value": "結束" }, + { + "name": "tray_open_app", + "value": "開啟ClashBox" + }, + { + "name": "tray_more_nodes", + "value": "更多節點…" + }, { "name": "app_already_exist", "value": "應用程式已存在" diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 index 9b843ff5..06b27836 100644 --- a/hvigor/hvigor-config.json5 +++ b/hvigor/hvigor-config.json5 @@ -1,5 +1,5 @@ { - "modelVersion": "5.1.0", + "modelVersion": "5.0.0", "dependencies": { }, "execution": { @@ -19,4 +19,4 @@ // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ } -} \ No newline at end of file +} diff --git a/latest_analysis.md b/latest_analysis.md new file mode 100644 index 00000000..e6af9041 --- /dev/null +++ b/latest_analysis.md @@ -0,0 +1,114 @@ +# ClashBox V2 periodic ClearConnections — latest analysis + +Date: 2026-08-11, data through ~14:29 UTC. Device: HarmonyOS PC, bundle +`org.xbgroup.clashbox` (store V2 line), UI pid 30723, VPN pid 31314. + +## TL;DR + +The ~180.5 s wipe timer is an ArkTS timer in the ClashBox **UI process** and it +fires **if and only if the UI process is executing JavaScript**. Measured by +UI-process utime/stime (not inferred from window state): + +- UI executing (window visible, or background keepalive held) → every grid + epoch wipes all Mihomo trackers (RPC method 11 ClearConnections) → all + long-lived flows get synchronized FIN. +- UI suspended (minimized here) → due fire-points are **skipped silently**; + no wipe; the VPN extension keeps relaying traffic normally. +- On the next wake, the most recent deferred fire executes **immediately** + (wipe lands within ~1 s of the first CPU tick). + +## The A/B/A evidence (all timestamps UTC) + +Steady grid while executing (both TUN fake-ip leg and 127.0.0.1:7890 leg die +within milliseconds of each other, FIN / rustls UnexpectedEof class): + + 13:35:57.60 13:38:57.93 13:41:58.10 13:44:58.64 13:47:58.87 + 13:50:59.60 13:54:00.27 13:57:01.17 14:00:01.33 14:03:01.54 + 14:06:01.65 14:09:02.55 14:12:02.69 14:15:03.05 + + spacing: 180.2–180.7 s (morning fit: 180.5004 s ± 0.5 s over 52 cycles; + phase anchored ~3 s after VPN/core process start) + +Minimize → suspend → wake sequence (3 s CPU sampling of pid 30723): + + 14:16:54 UI CPU goes flat (0 ms/s) — window minimized + 14:18:43.6 grid fire-point passes — NO wipe (blocked by suspension) + 14:19:07.5 UI wakes (first CPU tick) → deferred wipe fires 14:19:07.55 + 14:19:07–14:20:20 executing (~90–130 ms/s) + 14:20:20 flat again (minimized) + 14:22:08, 14:25:28 grid points pass — NO wipe (both blocked) + 14:28:38–41 UI wakes → deferred wipe fires 14:28:40.24 + +Earlier same-day confirmation: 13:07:23–13:14:04 the UI CPU was flat for 7+ +minutes and probes crossed 4 predicted epochs untouched, while the VPN kept +working (pongs continuous through ClashBox TUN and mixed port). + +Excluded as different failure class: 13:06:38–43 both legs died with TCP RST, +~5 s apart, and a brand-new connection was also reset — upstream/path blip, +not the grid bug (the grid bug is synchronized FIN within ≤15 ms). + +## What this pins down + +1. The caller is a periodic ArkTS timer in the V2 UI process (not the VPN + extension, not the core, not the system). +2. It invokes RPC method 11 ClearConnections over clash_go.sock (method settled + previously by two runtime discriminators: controller keep-alive survival + + absence of the "RESTful API listening at" re-listen log at epochs). +3. Any UI-executing state drives it: foreground window, or background execution + via a keepalive feature. Suspension stops it completely. +4. The wake-wipe behavior (deferred fire landing within ~1 s of wake) is + characteristic of a suspended JS interval timer catching up on resume. + +## Eliminated candidates + +- Profile/subscription auto-update: connection-host logging across epochs + shows zero subscription-URL fetches; the active profile is a local file://. +- LTS timers: no 180 s constant exists in public LTS source (all periodic tasks + are 0.9/1/1.5/9 s page-scoped, or 60 s profile auto-update). The ~180 s + timer is V2-added, app-level (fires regardless of which page is open). +- Window visibility per se: irrelevant except through its effect on UI-process + execution. + +## Open item: which V2 keepalive sustains it in the background + +Candidates (V2 settings vocabulary, from the May 2026 settings export): +长时任务 (backgroundKeepTask), 模拟画中画 (backgroundPiPModel), +模拟音频 (BackgroundAudioService), 模拟定位 (backgroundLocateModel), +模拟下载 (backgroundDownModel), master switch EnableBackgrounder. + +Bisection protocol (one change at a time, VPN left running): + +1. Enable exactly one keepalive; minimize the window. +2. Observe ≥3 predicted epochs (~10 min): + - UI CPU keeps growing + grid wipes continue → that feature sustains the + timer. + - UI CPU flat + wipes stop → it doesn't. +3. Disable again and confirm (ON→OFF→ON). + +## Practical mitigation (validated) + +To stop the wipes today: minimize the ClashBox window and keep all background +keepalive features off. The UI process suspends, the timer never fires, and +the VPN extension keeps forwarding normally. Caveat: any UI wake (opening the +window, or an enabled keepalive) fires the deferred wipe immediately. + +## Upstream fix direction + +In the V2 UI timer's callback, remove the routine ClearConnections call (or +replace it with selective removal of trackers that are actually dead). The +manual "clear connections" button must keep working. Nothing in LTS source, +Mihomo, or Codex should change. + +## Instrumentation currently running + +- Auto-restarting probe loops (TUN + 127.0.0.1:7890) logging every wipe with + ms timestamps: `tmp/mihomo-standalone-k3X9q/epoch-watch-long-131718/` +- 3 s UI/VPN CPU sampler: `tmp/mihomo-standalone-k3X9q/ui-cpu-samples.log` +- Lifecycle hilog capture: `tmp/mihomo-standalone-k3X9q/lifecycle-clean.hilog` +- Timeline joiner: `tmp/mihomo-standalone-k3X9q/analyze-bisection.py` + +## Related files + +- `INVESTIGATION-2026-08-11-synchronized-fin.md` — full investigation report +- `VERIFICATION-PROTOCOL.md` — install/bisection protocol +- `UPSTREAM-ISSUE-DRAFT.md` — issue draft for xiaobaigroup/ClashBox diff --git a/oh-package.json5 b/oh-package.json5 index 69cdc4ae..1a3eb77b 100644 --- a/oh-package.json5 +++ b/oh-package.json5 @@ -1,5 +1,5 @@ { - "modelVersion": "5.1.0", + "modelVersion": "5.0.0", "devDependencies": { "@ohos/hypium": "1.0.19", "@ohos/hamock": "1.0.0" @@ -11,4 +11,4 @@ "yaml": "^2.8.0", "@cxy/sandboxfinder": "^1.0.5" } -} \ No newline at end of file +} diff --git a/proxy_core/BuildProfile.ets b/proxy_core/BuildProfile.ets index 6033e79a..3a501e5d 100644 --- a/proxy_core/BuildProfile.ets +++ b/proxy_core/BuildProfile.ets @@ -2,8 +2,8 @@ * Use these variables when you tailor your ArkTS code. They must be of the const type. */ export const HAR_VERSION = '1.0.0'; -export const BUILD_MODE_NAME = 'release'; -export const DEBUG = false; +export const BUILD_MODE_NAME = 'debug'; +export const DEBUG = true; export const TARGET_NAME = 'default'; /** diff --git a/proxy_core/build-profile.json5 b/proxy_core/build-profile.json5 index 4aa088f9..4a420777 100644 --- a/proxy_core/build-profile.json5 +++ b/proxy_core/build-profile.json5 @@ -4,9 +4,9 @@ "externalNativeOptions": { "path": "./src/main/cpp/CMakeLists.txt", "arguments": "", - "abiFilters": ["arm64-v8a", "x86_64"], + "abiFilters": ["arm64-v8a"], "cppFlags": "" - }, + } }, "buildOptionSet": [ { @@ -30,7 +30,7 @@ "exclude": [] } } - }, + } ], "targets": [ { diff --git a/scripts/ci/create-openharmony-profile.mjs b/scripts/ci/create-openharmony-profile.mjs new file mode 100644 index 00000000..c98a8cec --- /dev/null +++ b/scripts/ci/create-openharmony-profile.mjs @@ -0,0 +1,31 @@ +import { randomUUID } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; + +const [templatePath, outputPath, bundleName] = process.argv.slice(2); + +if (!templatePath || !outputPath || !bundleName) { + console.error( + 'Usage: node create-openharmony-profile.mjs