diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 923b125..d26a3e2 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -2,7 +2,9 @@ name: PR Check on: pull_request: - branches: [ "master" ] + branches: ["master"] + +permissions: {} concurrency: group: pr-check-${{ github.event.pull_request.number || github.ref }} @@ -10,50 +12,255 @@ concurrency: env: CARGO_INCREMENTAL: 0 + CARGO_TERM_COLOR: always RUST_BACKTRACE: short jobs: - check: - name: Check (${{ matrix.target }}) + prepare: + name: Resolve the version + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.resolve.outputs.version }} + rpm_version: ${{ steps.resolve.outputs.rpm_version }} + steps: + - name: Check out the repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Derive the dev version + id: resolve + env: + NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + shell: bash + run: | + base="$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)" + if [ -z "$base" ]; then + echo "::error::could not read the version from Cargo.toml" + exit 1 + fi + version="${base%%-*}-dev.pr${NUMBER}.${HEAD_SHA:0:7}" + { + echo "version=$version" + echo "rpm_version=${version//-/\~}" + } >> "$GITHUB_OUTPUT" + + format: + name: Formatting + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check Rust formatting + run: cargo fmt --all --check + + lint: + name: Lint (${{ matrix.name }}) runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: fail-fast: false matrix: include: - - os: ubuntu-22.04 - target: x86_64-unknown-linux-gnu - - os: windows-latest - target: x86_64-pc-windows-msvc + - name: linux + os: ubuntu-latest + - name: windows + os: windows-latest steps: - - name: Checkout repository + - name: Check out the repository uses: actions/checkout@v7 + with: + persist-credentials: false - # gpui (X11/Wayland/Vulkan/font-kit) + tray-icon (GTK/appindicator/xdo). - - name: Install Linux dependencies + - name: Install the Linux dependencies if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y \ - libgtk-3-dev libxdo-dev libayatana-appindicator3-dev \ + libgtk-3-dev libxdo-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ libx11-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ libfontconfig1-dev libfreetype6-dev \ libvulkan-dev mesa-vulkan-drivers - - name: Install Rust toolchain + - name: Install the Rust toolchain uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.target }} - components: clippy, rustfmt + components: clippy + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + key: lint-${{ matrix.name }} + + - name: Lint with clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + build: + name: Build (${{ matrix.name }}) + needs: [prepare] + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-latest + - name: windows + os: windows-latest + steps: + - name: Check out the repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install the Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev libxdo-dev \ + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ + libx11-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libfontconfig1-dev libfreetype6-dev \ + libvulkan-dev mesa-vulkan-drivers + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@stable - name: Rust cache uses: swatinem/rust-cache@v2 + with: + key: build-${{ matrix.name }} + + - name: Stamp the dev version into Cargo.toml and Cargo.lock + env: + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: | + sed -i "0,/^version = \".*\"$/s//version = \"$VERSION\"/" Cargo.toml + sed -i '/^name = "nyx"$/,/^version = / s/^version = ".*/version = "'"$VERSION"'"/' Cargo.lock + grep -q "^version = \"$VERSION\"$" Cargo.toml + + - name: Build the release binary + run: cargo build --release --locked + + - name: Install the packaging tools + if: runner.os == 'Linux' + uses: taiki-e/install-action@v2 + with: + tool: cargo-deb,cargo-generate-rpm - - name: Cargo format check - run: cargo fmt --check + - name: Package for Linux + if: runner.os == 'Linux' + env: + VERSION: ${{ needs.prepare.outputs.version }} + RPM_VERSION: ${{ needs.prepare.outputs.rpm_version }} + shell: bash + run: | + mkdir -p dist + tar -C target/release -czf "dist/Nyx-${VERSION}-x86_64-linux.tar.gz" nyx + cargo deb -p nyx --no-build --output "dist/Nyx_${VERSION}_amd64.deb" + cargo generate-rpm -s "version = \"$RPM_VERSION\"" + cp target/generate-rpm/*.rpm "dist/Nyx-${RPM_VERSION}.x86_64.rpm" + + - name: Package for Windows + if: runner.os == 'Windows' + env: + VERSION: ${{ needs.prepare.outputs.version }} + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Compress-Archive -Path "target/release/nyx.exe" -DestinationPath "dist/Nyx-$env:VERSION-x86_64-windows.zip" -Force + + - name: Upload the Linux portable archive + if: runner.os == 'Linux' + uses: actions/upload-artifact@v7 + with: + name: Nyx-${{ needs.prepare.outputs.version }}-x86_64-linux.tar.gz + path: dist/Nyx-${{ needs.prepare.outputs.version }}-x86_64-linux.tar.gz + archive: false + if-no-files-found: error + retention-days: 7 + + - name: Upload the Debian package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v7 + with: + name: Nyx_${{ needs.prepare.outputs.version }}_amd64.deb + path: dist/Nyx_${{ needs.prepare.outputs.version }}_amd64.deb + archive: false + if-no-files-found: error + retention-days: 7 + + - name: Upload the RPM package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v7 + with: + name: Nyx-${{ needs.prepare.outputs.rpm_version }}.x86_64.rpm + path: dist/Nyx-${{ needs.prepare.outputs.rpm_version }}.x86_64.rpm + archive: false + if-no-files-found: error + retention-days: 7 - - name: Cargo check - run: cargo check --target ${{ matrix.target }} + - name: Upload the Windows portable archive + if: runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: Nyx-${{ needs.prepare.outputs.version }}-x86_64-windows.zip + path: dist/Nyx-${{ needs.prepare.outputs.version }}-x86_64-windows.zip + archive: false + if-no-files-found: error + retention-days: 7 - - name: Cargo clippy - run: cargo clippy --target ${{ matrix.target }} -- -D warnings + summary: + name: Report the check result + if: always() + needs: [prepare, format, lint, build] + runs-on: ubuntu-latest + steps: + - name: Publish the result to the run summary + env: + RESULTS: ${{ toJSON(needs.*.result) }} + VERSION: ${{ needs.prepare.outputs.version }} + RPM_VERSION: ${{ needs.prepare.outputs.rpm_version }} + shell: bash + run: | + { + echo "## PR check — \`$VERSION\`" + echo + echo '| Job | Result |' + echo '| --- | --- |' + echo '| Formatting | ${{ needs.format.result }} |' + echo '| Lint | ${{ needs.lint.result }} |' + echo '| Build | ${{ needs.build.result }} |' + echo + echo '| Asset | Platform |' + echo '| --- | --- |' + echo "| \`Nyx-${VERSION}-x86_64-windows.zip\` | Windows portable |" + echo "| \`Nyx-${VERSION}-x86_64-linux.tar.gz\` | Linux portable |" + echo "| \`Nyx_${VERSION}_amd64.deb\` | Debian/Ubuntu |" + echo "| \`Nyx-${RPM_VERSION}.x86_64.rpm\` | Fedora/RHEL |" + echo + echo 'Dev builds are attached to this run as artifacts and expire after 7 days.' + } >> "$GITHUB_STEP_SUMMARY" + if printf '%s' "$RESULTS" | grep -qE 'failure|cancelled'; then + echo "::error::the PR check did not pass" + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad862b8..80e8ae5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,211 +1,246 @@ -name: Release Build +name: Publish Release on: workflow_dispatch: inputs: version: - description: "Release version (e.g. 1.2.3)" + description: Version to release (e.g. 2.1.0) required: true type: string prerelease: - description: "Mark as pre-release" - required: false + description: Mark the release as a pre-release + default: false type: boolean + draft: + description: Publish the release as a draft default: false + type: boolean -permissions: - contents: write +permissions: {} concurrency: - group: release-${{ github.workflow }}-${{ inputs.version }} + group: release-${{ github.ref }} cancel-in-progress: false env: CARGO_INCREMENTAL: 0 + CARGO_TERM_COLOR: always RUST_BACKTRACE: short jobs: prepare: - name: Prepare Release Metadata + name: Resolve the version runs-on: ubuntu-latest permissions: - contents: write + contents: read outputs: - tag: ${{ steps.meta.outputs.tag }} - previous_tag: ${{ steps.prev_tag.outputs.latest_tag }} - changelog: ${{ steps.changelog.outputs.changelog }} + version: ${{ steps.resolve.outputs.version }} + rpm_version: ${{ steps.resolve.outputs.rpm_version }} + tag: ${{ steps.resolve.outputs.tag }} + previous: ${{ steps.resolve.outputs.previous }} steps: - - name: Checkout repository + - name: Check out the repository uses: actions/checkout@v7 with: - ref: ${{ github.ref }} fetch-depth: 0 - fetch-tags: true + persist-credentials: false - - name: Validate version format + - name: Validate the version and find the previous tag + id: resolve + env: + VERSION: ${{ inputs.version }} shell: bash run: | - if ! [[ "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid version: ${{ inputs.version }}" - echo "Expected semver format, e.g. 1.2.3 or 1.2.3-rc.1" + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::'$VERSION' is not a semantic version" exit 1 fi - - - name: Set version in Cargo.toml / Cargo.lock / flake.nix - shell: bash - run: | - VERSION="${{ inputs.version }}" - sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" Cargo.toml - sed -i '/^name = "nyx"$/,/^version = / s/^version = ".*/version = "'"$VERSION"'"/' Cargo.lock - sed -i '/pname = "nyx";/,/version = / s/version = "[^"]*";/version = "'"$VERSION"'";/' flake.nix - - - name: Commit version bump - uses: stefanzweifel/git-auto-commit-action@v7 - with: - commit_message: "chore(release): bump version to ${{ inputs.version }}" - - - name: Compute release tag - id: meta - shell: bash - run: echo "tag=v${{ inputs.version }}" >> "$GITHUB_OUTPUT" - - - name: Find previous release tag - id: prev_tag - shell: bash - run: | - latest_tag="$(git tag --list 'v*' --sort=-version:refname | grep -vx "v${{ inputs.version }}" | head -n 1)" - if [ -z "$latest_tag" ]; then - latest_tag="v${{ inputs.version }}" + if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then + echo "::error::tag v$VERSION already exists" + exit 1 fi - echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" - - - name: Push release tag - shell: bash - run: | - git tag -f "v${{ inputs.version }}" HEAD - git push origin "v${{ inputs.version }}" --force - - - name: Build Changelog - id: changelog - uses: mikepenz/release-changelog-builder-action@v6 + { + echo "version=$VERSION" + echo "rpm_version=${VERSION//-/\~}" + echo "tag=v$VERSION" + echo "previous=$(git tag --list 'v*' --sort=-v:refname | head -n1)" + } >> "$GITHUB_OUTPUT" + + format: + name: Formatting + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the repository + uses: actions/checkout@v7 with: - mode: COMMIT - fromTag: ${{ steps.prev_tag.outputs.latest_tag }} - toTag: v${{ inputs.version }} - configuration: ".github/changelog_configuration.json" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false - - name: Create draft release - uses: softprops/action-gh-release@v3 + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@stable with: - tag_name: v${{ inputs.version }} - name: Nyx ${{ inputs.version }} - body: Draft release. Assets are uploaded by the build matrix. - draft: true - prerelease: ${{ inputs.prerelease }} - token: ${{ secrets.GITHUB_TOKEN }} + components: rustfmt + + - name: Check Rust formatting + run: cargo fmt --all --check - build-windows: - name: Build (Windows) - needs: [prepare] - runs-on: windows-latest + lint: + name: Lint (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-latest + - name: windows + os: windows-latest steps: - - name: Checkout repository + - name: Check out the repository uses: actions/checkout@v7 with: - ref: v${{ inputs.version }} + persist-credentials: false + + - name: Install the Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev libxdo-dev \ + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ + libx11-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libfontconfig1-dev libfreetype6-dev \ + libvulkan-dev mesa-vulkan-drivers - - name: Install Rust toolchain + - name: Install the Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + components: clippy - name: Rust cache uses: swatinem/rust-cache@v2 + with: + key: lint-${{ matrix.name }} - - name: Build release binary - run: cargo build --release + - name: Lint with clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings - - name: Portable zip - shell: pwsh - run: | - $name = "Nyx-x86_64-windows.zip" - Compress-Archive -Path "target/release/nyx.exe" -DestinationPath $name -Force - - - name: Install NSIS - run: choco install nsis -y --no-progress + tag: + name: Bump the version and tag + needs: [prepare, format, lint] + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + sha: ${{ steps.push.outputs.sha }} + steps: + - name: Check out the repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 - - name: Build installer (NSIS) - shell: pwsh + - name: Set the version in Cargo.toml, Cargo.lock and flake.nix + env: + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash run: | - $exe = (Resolve-Path "target/release/nyx.exe").Path - & "C:\Program Files (x86)\NSIS\makensis.exe" ` - "/DAPPVERSION=${{ inputs.version }}" "/DSOURCEEXE=$exe" ` - installer/windows/nyx.nsi - Copy-Item "installer/windows/Nyx_${{ inputs.version }}_x64-setup.exe" . - - - name: Upload release assets - uses: softprops/action-gh-release@v3 - with: - tag_name: v${{ inputs.version }} - files: | - Nyx-x86_64-windows.zip - Nyx_${{ inputs.version }}_x64-setup.exe - token: ${{ secrets.GITHUB_TOKEN }} + sed -i "0,/^version = \".*\"$/s//version = \"$VERSION\"/" Cargo.toml + sed -i '/^name = "nyx"$/,/^version = / s/^version = ".*/version = "'"$VERSION"'"/' Cargo.lock + sed -i '/pname = "nyx";/,/version = / s/version = "[^"]*";/version = "'"$VERSION"'";/' flake.nix + grep -q "^version = \"$VERSION\"$" Cargo.toml + grep -q "version = \"$VERSION\";" flake.nix - build-linux: - name: Build (Linux) - needs: [prepare] - runs-on: ubuntu-24.04 + - name: Commit the bump and push the tag + id: push + env: + VERSION: ${{ needs.prepare.outputs.version }} + TAG: ${{ needs.prepare.outputs.tag }} + shell: bash + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add Cargo.toml Cargo.lock flake.nix + git commit -m "chore(release): bump version to $VERSION" + git tag -a "$TAG" -m "$VERSION" + git push origin HEAD:"${GITHUB_REF_NAME}" + git push origin "$TAG" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + build: + name: Build (${{ matrix.name }}) + needs: [prepare, tag] + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-latest + - name: windows + os: windows-latest steps: - - name: Checkout repository + - name: Check out the tagged commit uses: actions/checkout@v7 with: - ref: v${{ inputs.version }} + ref: ${{ needs.tag.outputs.sha }} + persist-credentials: false - # gpui (X11/Wayland/Vulkan/font-kit) + tray-icon (GTK/appindicator/xdo). - - name: Install Linux dependencies + - name: Install the Linux dependencies + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y \ - libgtk-3-dev libxdo-dev libayatana-appindicator3-dev \ + libgtk-3-dev libxdo-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ libx11-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ libfontconfig1-dev libfreetype6-dev \ libvulkan-dev mesa-vulkan-drivers - - name: Install Rust toolchain + - name: Install the Rust toolchain uses: dtolnay/rust-toolchain@stable - name: Rust cache uses: swatinem/rust-cache@v2 + with: + key: build-${{ matrix.name }} - # No --target: the binary lands in target/release/nyx, which the - # cargo-deb / cargo-generate-rpm asset paths and the PKGBUILD expect. - - name: Build release binary - run: cargo build --release + - name: Build the release binary + run: cargo build --release --locked - - name: Install packaging tools + - name: Install the packaging tools + if: runner.os == 'Linux' uses: taiki-e/install-action@v2 with: tool: cargo-deb,cargo-generate-rpm - - name: Portable tarball - run: tar -C target/release -czf Nyx-x86_64-linux.tar.gz nyx - - - name: Build .deb - run: cargo deb --no-build --output Nyx_${{ inputs.version }}_amd64.deb - - - name: Build .rpm + - name: Package for Linux + if: runner.os == 'Linux' + env: + VERSION: ${{ needs.prepare.outputs.version }} + RPM_VERSION: ${{ needs.prepare.outputs.rpm_version }} + shell: bash run: | - cargo generate-rpm - cp target/generate-rpm/*.rpm "Nyx-${{ inputs.version }}.x86_64.rpm" - - - name: Build .pkg.tar.xz (Arch) + mkdir -p dist + tar -C target/release -czf "dist/Nyx-x86_64-linux.tar.gz" nyx + cargo deb -p nyx --no-build --output "dist/Nyx_${VERSION}_amd64.deb" + cargo generate-rpm -s "version = \"$RPM_VERSION\"" + cp target/generate-rpm/*.rpm "dist/Nyx-${RPM_VERSION}.x86_64.rpm" + + - name: Package for Arch + if: runner.os == 'Linux' + env: + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash run: | - cp target/release/nyx installer/arch/nyx - cp installer/linux/nyx.desktop installer/arch/nyx.desktop - cp assets/brand/logo.png installer/arch/nyx.png - docker run --rm -v "$PWD/installer/arch:/pkg" -e VER="${{ inputs.version }}" \ + cp target/release/nyx installer/arch/nyx + cp installer/linux/nyx.desktop installer/arch/nyx.desktop + cp assets/brand/logo.png installer/arch/nyx.png + docker run --rm -v "$PWD/installer/arch:/pkg" -e VER="$VERSION" \ archlinux:latest bash -c ' pacman -Sy --noconfirm base-devel >/dev/null useradd -m builder @@ -215,29 +250,96 @@ jobs: sudo -u builder env PKGEXT=.pkg.tar.xz makepkg -f --nodeps cp *.pkg.tar.xz /pkg/ ' - cp installer/arch/*.pkg.tar.xz "Nyx-${{ inputs.version }}-x86_64.pkg.tar.xz" + cp installer/arch/*.pkg.tar.xz "dist/Nyx-${VERSION}-x86_64.pkg.tar.xz" - - name: Upload release assets - uses: softprops/action-gh-release@v3 + - name: Package for Windows + if: runner.os == 'Windows' + env: + VERSION: ${{ needs.prepare.outputs.version }} + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Compress-Archive -Path "target/release/nyx.exe" -DestinationPath "dist/Nyx-x86_64-windows.zip" -Force + choco install nsis -y --no-progress + $exe = (Resolve-Path "target/release/nyx.exe").Path + & "C:\Program Files (x86)\NSIS\makensis.exe" ` + "/DAPPVERSION=$env:VERSION" "/DSOURCEEXE=$exe" ` + installer/windows/nyx.nsi + Copy-Item "installer/windows/Nyx_${env:VERSION}_x64-setup.exe" dist/ + + - name: Upload the Linux portable archive + if: runner.os == 'Linux' + uses: actions/upload-artifact@v7 with: - tag_name: v${{ inputs.version }} - files: | - Nyx-x86_64-linux.tar.gz - Nyx_${{ inputs.version }}_amd64.deb - Nyx-${{ inputs.version }}.x86_64.rpm - Nyx-${{ inputs.version }}-x86_64.pkg.tar.xz - token: ${{ secrets.GITHUB_TOKEN }} + name: Nyx-x86_64-linux.tar.gz + path: dist/Nyx-x86_64-linux.tar.gz + archive: false + if-no-files-found: error + retention-days: 1 + + - name: Upload the Debian package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v7 + with: + name: Nyx_${{ needs.prepare.outputs.version }}_amd64.deb + path: dist/Nyx_${{ needs.prepare.outputs.version }}_amd64.deb + archive: false + if-no-files-found: error + retention-days: 1 + + - name: Upload the RPM package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v7 + with: + name: Nyx-${{ needs.prepare.outputs.rpm_version }}.x86_64.rpm + path: dist/Nyx-${{ needs.prepare.outputs.rpm_version }}.x86_64.rpm + archive: false + if-no-files-found: error + retention-days: 1 + + - name: Upload the Arch package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v7 + with: + name: Nyx-${{ needs.prepare.outputs.version }}-x86_64.pkg.tar.xz + path: dist/Nyx-${{ needs.prepare.outputs.version }}-x86_64.pkg.tar.xz + archive: false + if-no-files-found: error + retention-days: 1 + + - name: Upload the Windows portable archive + if: runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: Nyx-x86_64-windows.zip + path: dist/Nyx-x86_64-windows.zip + archive: false + if-no-files-found: error + retention-days: 1 + + - name: Upload the Windows installer + if: runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: Nyx_${{ needs.prepare.outputs.version }}_x64-setup.exe + path: dist/Nyx_${{ needs.prepare.outputs.version }}_x64-setup.exe + archive: false + if-no-files-found: error + retention-days: 1 cachix: name: Push to Cachix - needs: [prepare] - runs-on: ubuntu-latest + needs: [tag] if: ${{ ! inputs.prerelease }} + runs-on: ubuntu-latest + permissions: + contents: read steps: - - name: Checkout repository + - name: Check out the tagged commit uses: actions/checkout@v7 with: - ref: v${{ inputs.version }} + ref: ${{ needs.tag.outputs.sha }} + persist-credentials: false - name: Install Nix uses: cachix/install-nix-action@v31 @@ -251,20 +353,98 @@ jobs: name: bx-team authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - name: Build (and push) the flake package + - name: Build and push the flake package run: nix build --print-build-logs .#nyx publish: - name: Publish release - needs: [prepare, build-windows, build-linux] + name: Publish the release + needs: [prepare, tag, build] runs-on: ubuntu-latest + permissions: + contents: write steps: - - name: Finalize release with changelog + - name: Check out the tagged commit + uses: actions/checkout@v7 + with: + ref: ${{ needs.tag.outputs.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Download the release assets + uses: actions/download-artifact@v8 + with: + pattern: Nyx* + merge-multiple: true + path: dist + + - name: Verify the release assets + env: + VERSION: ${{ needs.prepare.outputs.version }} + RPM_VERSION: ${{ needs.prepare.outputs.rpm_version }} + shell: bash + run: | + for asset in \ + "Nyx-x86_64-linux.tar.gz" \ + "Nyx_${VERSION}_amd64.deb" \ + "Nyx-${RPM_VERSION}.x86_64.rpm" \ + "Nyx-${VERSION}-x86_64.pkg.tar.xz" \ + "Nyx-x86_64-windows.zip" \ + "Nyx_${VERSION}_x64-setup.exe"; do + if [ ! -f "dist/$asset" ]; then + echo "::error::missing release asset '$asset'" + exit 1 + fi + done + + - name: Build the changelog + id: changelog + uses: mikepenz/release-changelog-builder-action@v6 + with: + configuration: .github/changelog_configuration.json + mode: COMMIT + fromTag: ${{ needs.prepare.outputs.previous }} + toTag: ${{ needs.prepare.outputs.tag }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish the GitHub release uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.prepare.outputs.tag }} - name: Nyx ${{ needs.prepare.outputs.tag }} - body: ${{ needs.prepare.outputs.changelog }} - draft: false + name: Nyx ${{ needs.prepare.outputs.version }} + body: ${{ steps.changelog.outputs.changelog }} + files: dist/* + draft: ${{ inputs.draft }} prerelease: ${{ inputs.prerelease }} token: ${{ secrets.GITHUB_TOKEN }} + + summary: + name: Report the release result + if: always() + needs: [prepare, format, lint, tag, build, cachix, publish] + runs-on: ubuntu-latest + steps: + - name: Publish the result to the run summary + env: + RESULTS: ${{ toJSON(needs.*.result) }} + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + RPM_VERSION: ${{ needs.prepare.outputs.rpm_version }} + shell: bash + run: | + { + echo "## $TAG" + echo + echo '| Asset | Platform |' + echo '| --- | --- |' + echo "| \`Nyx_${VERSION}_x64-setup.exe\` | Windows installer |" + echo "| \`Nyx-x86_64-windows.zip\` | Windows portable |" + echo "| \`Nyx-x86_64-linux.tar.gz\` | Linux portable |" + echo "| \`Nyx_${VERSION}_amd64.deb\` | Debian/Ubuntu |" + echo "| \`Nyx-${RPM_VERSION}.x86_64.rpm\` | Fedora/RHEL |" + echo "| \`Nyx-${VERSION}-x86_64.pkg.tar.xz\` | Arch |" + } >> "$GITHUB_STEP_SUMMARY" + if printf '%s' "$RESULTS" | grep -qE 'failure|cancelled'; then + echo "::error::release $TAG did not complete" + exit 1 + fi diff --git a/CLAUDE.md b/CLAUDE.md index 5b9bcb9..87e24a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,8 @@ Before every commit, the same checks CI runs must pass: `cargo fmt --check`, `ca ### Linux platform - Tray: Linux uses a pure D-Bus StatusNotifierItem via `ksni` (no gtk / appindicator). `tray-icon` is cfg-gated to non-Linux, and the two backends diverge inside `app/tray.rs` — keep new tray logic behind the right `cfg`. - System proxy (`backend/sysproxy.rs`): on Linux we set both GSettings and the proxy env vars (systemd user manager + D-Bus activation env). Only GNOME-like desktops reliably honor the GSettings proxy — `session_honors_proxy()` gates the "partial coverage" note; TUN is the full-device path. -- NixOS: detected via `/etc/NIXOS` (`elevation::is_nixos()`). TUN caps come from `programs.nyx.tunMode` (declarative capability wrapper), not a runtime `setcap`, so the Settings grant button is swapped for instructions there. The flake exposes `packages.nyx` / the `nyx` app in addition to the dev shell. +- Privileged service (`crates/nyx-service`): the unit goes to the first writable dir of `/etc/systemd/system` → `/usr/local/lib/systemd/system` → `/run/systemd/system` (NixOS `/etc` is a read-only store symlink). Elevation is `pkexec` on the app binary itself; pkexec wipes `PATH` and detaches from the tty, so resolve helper binaries absolutely and expect to bring up a polkit agent first. `CapabilityBoundingSet` must keep `CAP_CHOWN` or the host cannot hand its socket to the GUI user. +- NixOS: the flake exposes `nixosModules.nyx`/`.default` (`nix/module.nix`) next to `packages.nyx`, the `nyx` app and the dev shell. `programs.nyx.enable` declares the unit and drops `/etc/nyx/service-managed`, which makes the app treat the service as read-only (no install/uninstall, no `Stale` on an ExecStart mismatch). A declarative unit passes the owner as a user name, so `--nyx-service-owner` takes either a uid or a name. ## Bash Guidelines - Don't pipe output through `head`/`tail`/`less` to truncate — use tool-native flags (`git log -n 10`, `cargo clippy --message-format=short`). Read the full output. diff --git a/Cargo.lock b/Cargo.lock index 886597f..e96b56a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1060,15 +1060,6 @@ dependencies = [ "wayland-client", ] -[[package]] -name = "caps" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd1ddba47aba30b6a889298ad0109c3b8dcb0e8fc993b459daa7067d46f865e0" -dependencies = [ - "libc", -] - [[package]] name = "cbc" version = "0.1.2" @@ -5167,12 +5158,11 @@ dependencies = [ [[package]] name = "nyx" -version = "2.0.6" +version = "2.1.0" dependencies = [ "anyhow", "auto-launch", "base64 0.23.1", - "caps", "chrono", "dirs", "emojis", @@ -5189,6 +5179,8 @@ dependencies = [ "log", "md-5 0.11.0", "mihomo-rs", + "nyx-service", + "nyx-sysproxy", "once_cell", "parking_lot", "png 0.18.1", @@ -5204,11 +5196,36 @@ dependencies = [ "tray-icon", "url", "windows 0.62.2", - "windows-service", "winreg 0.56.0", "winresource", ] +[[package]] +name = "nyx-service" +version = "1.0.0" +dependencies = [ + "chrono", + "libc", + "log", + "serde", + "serde_json", + "tokio", + "windows 0.62.2", + "windows-service", +] + +[[package]] +name = "nyx-sysproxy" +version = "1.0.0" +dependencies = [ + "dirs", + "log", + "thiserror 2.0.18", + "url", + "windows 0.62.2", + "winreg 0.56.0", +] + [[package]] name = "objc" version = "0.2.7" diff --git a/Cargo.toml b/Cargo.toml index 86f1813..2aa808a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,12 @@ +[workspace] +members = ["crates/*"] + [package] name = "nyx" -version = "2.0.6" +version = "2.1.0" description = "Nyx — Mihomo/Clash GUI" authors = ["BX Team"] -edition = "2021" +edition = "2024" license = "GPL-3.0-or-later" homepage = "https://github.com/BX-Team/Nyx" repository = "https://github.com/BX-Team/Nyx" @@ -64,6 +67,8 @@ futures-util = "0.3" base64 = "0.23" md-5 = "0.11" emojis = "0.9" +nyx-sysproxy = { path = "crates/nyx-sysproxy" } +nyx-service = { path = "crates/nyx-service" } # --- system integration --- global-hotkey = "0.8" @@ -81,10 +86,9 @@ self_update = { version = "0.44", default-features = false, features = [ ] } [target.'cfg(windows)'.dependencies] -windows-service = "0.8" -winreg = "0.56" raw-window-handle = "0.6" image = "0.25" +winreg = "0.56" windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_Security", @@ -101,16 +105,12 @@ tray-icon = "0.24" libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] -caps = "0.5" ksni = { version = "0.3", default-features = false, features = ["async-io"] } [target.'cfg(windows)'.build-dependencies] winresource = "0.1" # --- Linux packaging (cargo-deb / cargo-generate-rpm) --- -# These read `target/release/nyx`, so the Linux release job builds without an -# explicit --target (the ubuntu runner is x86_64 anyway). - [package.metadata.deb] maintainer = "BX Team" copyright = "BX Team, GPL-3.0-or-later" diff --git a/README.md b/README.md index 7583421..8ef79cd 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,20 @@ A modern, lightweight desktop GUI for the [Mihomo](https://github.com/MetaCubeX/ -# Preview +## 🖼️ Preview ![preview](.github/branding/preview.png) -# Installation +## 📦 Installation Grab the latest build from the [Releases page](https://github.com/BX-Team/Nyx/releases/latest). -## Windows (x86_64) +### Windows (x86_64) - **Installer:** `Nyx__x64-setup.exe` — run it and follow the prompts. On first launch Nyx asks for elevation to install the helper service required for TUN mode; accept it once and you are set. - **Portable:** `Nyx-x86_64-windows.zip` — unzip anywhere and run `nyx.exe`. No install, settings live in your user data dir. -## Linux (x86_64) +### Linux (x86_64) Pick the package for your distro, or the portable tarball: @@ -33,7 +33,7 @@ Pick the package for your distro, or the portable tarball: - **Arch:** `Nyx--x86_64.pkg.tar.xz` — `sudo pacman -U ./Nyx--x86_64.pkg.tar.xz` - **Portable:** `Nyx-x86_64-linux.tar.gz` — extract and run `./nyx` -### Nix +## ❄️ Nix Nyx ships a flake. Run it directly without installing: @@ -89,67 +89,50 @@ Then add the package to your `environment.systemPackages` or `home.packages`: } ``` -To pull a **prebuilt** binary from the Cachix cache instead of compiling locally, add the substituter and its public key: - -```nix -nix = { - settings = { - substituters = [ - "https://bx-team.cachix.org" - ]; - trusted-public-keys = [ - "bx-team.cachix.org-1:tnGNc1rsS8QOav+VGxXCZzf/Y0/SGchOwVCCBA/eG6E=" - ]; - }; -}; -``` - ### NixOS module -The flake also exposes a NixOS module. Import it and enable Nyx declaratively: +On NixOS the `/etc` unit directory is read-only, so the in-app installer has to +fall back to `/usr/local/lib/systemd/system` and asks polkit for a password +after every rebuild that moves the binary. The module declares the service +instead — no password prompt, ever, and TUN works out of the box: ```nix -{ +# NixOS configuration +{ inputs, ... }: { imports = [ inputs.nyx.nixosModules.default ]; programs.nyx = { enable = true; - - # TUN/VPN mode. Wraps the binary with cap_net_admin/cap_net_raw/ - # cap_net_bind_service so the mihomo core can create a TUN device - # without running as root. Leave off to use only the system proxy. - tunMode = true; - - # Subscription URLs seeded into Nyx on launch, so you never add them by - # hand. Idempotent — already-added URLs are skipped and a failed fetch - # is retried next launch. Profile names come from the subscription. - profiles = [ - "https://example.com/subscription" - ]; - - # Same as `profiles`, but read from a file (whitespace/newline - # separated). Use this for secret URLs rendered by sops/agenix so they - # never land in the world-readable Nix store. - profilesFile = "/run/secrets/nyx-profiles"; + # The account you run the GUI from; only it may drive the service. + service.user = "alice"; }; } ``` -Options: +The module installs the package too, so it replaces the `environment.systemPackages` +entry above. With it in place Nyx hides the install/uninstall buttons in +**Settings → System service** and leaves the unit to your configuration. Set +`programs.nyx.service.enable = false` if you would rather manage the core +yourself in direct mode. -| Option | Type | Default | Effect | -| -------------- | --------------- | -------------- | ------------------------------------------------------------------------------------------- | -| `enable` | bool | `false` | Installs Nyx and enables dconf + gnome-keyring (needed for the GSettings proxy and secrets). | -| `package` | package | flake's `nyx` | The Nyx package to use. | -| `tunMode` | bool | `false` | Grants the net capabilities for TUN mode via a `security.wrappers` entry (no runtime setcap). | -| `profiles` | list of str | `[]` | Subscription URLs auto-imported on launch. | -| `profilesFile` | null or path | `null` | Path to a file of subscription URLs, imported like `profiles` — for secrets kept out of the store. | +To pull a **prebuilt** binary from the Cachix cache instead of compiling locally, add the substituter and its public key: -`profiles`/`profilesFile` are passed to Nyx by wrapping the binary with the `NYX_PROFILES` / `NYX_PROFILES_FILE` environment variables, so they reach the app however the desktop launches it. After changing them, rebuild and relaunch Nyx; the import runs on the next start. +```nix +nix = { + settings = { + substituters = [ + "https://bx-team.cachix.org" + ]; + trusted-public-keys = [ + "bx-team.cachix.org-1:tnGNc1rsS8QOav+VGxXCZzf/Y0/SGchOwVCCBA/eG6E=" + ]; + }; +}; +``` -## Build from source +## 🔨 Build from source -Nyx is now a single pure-Rust [gpui](https://github.com/zed-industries/zed) application. The only hard requirement is a stable [Rust](https://www.rust-lang.org/tools/install) toolchain. +Nyx is a single pure-Rust [gpui](https://github.com/zed-industries/zed) application. The only hard requirement is a stable [Rust](https://www.rust-lang.org/tools/install) toolchain. ```bash git clone https://github.com/BX-Team/Nyx.git @@ -171,16 +154,15 @@ sudo apt-get install -y \ Or just use the flake: `nix develop` drops you into a shell with everything wired up. +## 🤝 Contributing -# License - -This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. +We welcome contributions to Nyx! If you have an idea for a new feature or found a bug, please feel free to submit a pull request. Before you start, please read our [contributing guidelines](CONTRIBUTING.md) to understand our contribution process. -# Contributing +## ⚖️ License -We welcome contributions to Nyx! If you have an idea for a new feature or found a bug, please feel free to submit a pull request. Before you start, please read our [contributing guidelines](CONTRIBUTING.md) to understand our contribution process. +This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. -# Credits +## 💛 Credits Nyx was based on or inspired by these projects: @@ -188,3 +170,4 @@ Nyx was based on or inspired by these projects: - [DINGDANGMAOUP/mihomo-rs](https://github.com/DINGDANGMAOUP/mihomo-rs): A Rust SDK for Mihomo, manages versions, configs and other things. - [zed-industries/zed](https://github.com/zed-industries/zed): Home of the [gpui](https://www.gpui.rs/) GPU-accelerated UI framework that Nyx is built on. - [longbridge/gpui-component](https://github.com/longbridge/gpui-component): The gpui component library powering Nyx's widgets. +- [zzzgydi/sysproxy-rs](https://github.com/zzzgydi/sysproxy-rs): Vendored and trimmed as `crates/nyx-sysproxy` — reads and writes the OS system proxy on Windows and Linux. diff --git a/crates/nyx-service/Cargo.toml b/crates/nyx-service/Cargo.toml new file mode 100644 index 0000000..392e41a --- /dev/null +++ b/crates/nyx-service/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "nyx-service" +version = "1.0.0" +description = "Privileged mihomo supervisor for Nyx (Windows SCM / systemd)" +edition = "2024" +license = "GPL-3.0-or-later" + +[lib] +name = "nyx_service" + +[dependencies] +log = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = "0.4" +tokio = { version = "1", features = [ + "rt-multi-thread", + "net", + "process", + "io-util", + "sync", + "time", + "macros", + "signal", +] } + +[target.'cfg(windows)'.dependencies] +windows-service = "0.8" +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/nyx-service/src/control/linux.rs b/crates/nyx-service/src/control/linux.rs new file mode 100644 index 0000000..3fb73cc --- /dev/null +++ b/crates/nyx-service/src/control/linux.rs @@ -0,0 +1,686 @@ +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; + +use crate::control::Status; +use crate::host::linux::SOCKET_PATH; +use crate::logging; +use crate::protocol::{CoreSpec, PROTOCOL_VERSION, Request, Response}; +use crate::{ + ARG_CONTROL, ARG_HOST, ARG_INSTALL, ARG_OWNER, ARG_UNINSTALL, HELPER_FAILURE, is_elevated, +}; + +pub const UNIT_NAME: &str = "nyx.service"; +const UNIT_DIRS: [&str; 3] = [ + "/etc/systemd/system", + "/usr/local/lib/systemd/system", + "/run/systemd/system", +]; +const WANTS_DIR: &str = "multi-user.target.wants"; +const MANAGED_MARKER: &str = "/etc/nyx/service-managed"; + +const CONNECT_GRACE: Duration = Duration::from_secs(10); + +const UNIT_TEMPLATE: &str = r#"[Unit] +Description=Nyx Service (mihomo core supervisor) +After=network.target NetworkManager.service systemd-networkd.service iwd.service + +[Service] +Type=simple +LimitNPROC=500 +LimitNOFILE=1000000 +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_CHOWN CAP_FOWNER +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE +Restart=always +RestartSec=2 +RuntimeDirectory=nyx +RuntimeDirectoryMode=0755 +ExecStart={exe} {arg_host} {arg_owner} {uid} + +[Install] +WantedBy=multi-user.target +"#; + +pub fn is_managed() -> bool { + Path::new(MANAGED_MARKER).exists() +} + +pub async fn status() -> Result { + let Some(exec_start) = unit_exec_start() else { + return Ok(Status::NotInstalled); + }; + + let current = std::env::current_exe().map_err(|e| e.to_string())?; + if !is_managed() && exec_start != current { + return Ok(Status::Stale { + reason: format!( + "the service points at {}, this build runs from {}", + exec_start.display(), + current.display() + ), + }); + } + + if !unit_is_active() { + return Ok(Status::Stopped); + } + match ping().await { + Ok(_) => Ok(Status::Running), + Err(reason) => Ok(Status::Stale { reason }), + } +} + +pub async fn install() -> Result<(), String> { + require_unmanaged()?; + require_systemd()?; + if is_elevated() { + install_here(owner_uid())?; + } else { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let uid = owner_uid().to_string(); + run_privileged(&exe, &[ARG_INSTALL, ARG_OWNER, &uid]).await?; + } + wait_for_socket(CONNECT_GRACE).await?; + ping().await.map(|_| ()) +} + +pub async fn uninstall() -> Result<(), String> { + require_unmanaged()?; + if unit_exec_start().is_none() { + return Ok(()); + } + let _ = stop_core().await; + if is_elevated() { + uninstall_here()?; + } else { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + run_privileged(&exe, &[ARG_UNINSTALL]).await?; + } + Ok(()) +} + +pub async fn start_service() -> Result<(), String> { + control("start").await?; + wait_for_socket(CONNECT_GRACE).await?; + ping().await.map(|_| ()) +} + +pub async fn stop_service() -> Result<(), String> { + if unit_exec_start().is_none() { + return Ok(()); + } + control("stop").await +} + +pub async fn restart_service() -> Result<(), String> { + control("restart").await?; + wait_for_socket(CONNECT_GRACE).await?; + ping().await.map(|_| ()) +} + +async fn control(action: &'static str) -> Result<(), String> { + if unit_exec_start().is_none() { + return Err("the Nyx service is not installed".into()); + } + if is_elevated() { + return control_here(action); + } + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + run_privileged(&exe, &[ARG_CONTROL, action]).await +} + +pub fn control_here(action: &str) -> Result<(), String> { + match action { + "start" | "stop" | "restart" => systemctl(&[action, UNIT_NAME]), + other => Err(format!("unknown service action {other:?}")), + } +} + +pub async fn start_core(spec: &CoreSpec) -> Result { + match status().await? { + Status::NotInstalled => return Err("the Nyx service is not installed".into()), + Status::Running => {} + _ => install().await?, + } + match request(&Request::StartCore(spec.clone())).await? { + Response::Started { pid } => Ok(pid), + Response::Error { message } => Err(message), + other => Err(format!("unexpected service response: {other:?}")), + } +} + +pub async fn stop_core() -> Result<(), String> { + if !Path::new(SOCKET_PATH).exists() { + return Ok(()); + } + match request(&Request::StopCore).await? { + Response::Ok => Ok(()), + Response::Error { message } => Err(message), + other => Err(format!("unexpected service response: {other:?}")), + } +} + +/// `Ok(core_pid)` — `None` means the host is up but no core is running. +pub async fn ping() -> Result, String> { + match request(&Request::Ping).await? { + Response::Pong { + protocol_version, + core_pid, + } if protocol_version == PROTOCOL_VERSION => Ok(core_pid), + Response::Pong { + protocol_version, .. + } => Err(format!( + "service speaks protocol v{protocol_version}, this build expects v{PROTOCOL_VERSION}" + )), + Response::Error { message } => Err(message), + other => Err(format!("unexpected service response: {other:?}")), + } +} + +fn require_unmanaged() -> Result<(), String> { + if !is_managed() { + return Ok(()); + } + Err( + "the Nyx service is provided by your system configuration, so Nyx cannot install or \ + remove it" + .into(), + ) +} + +fn require_systemd() -> Result<(), String> { + if Path::new("/run/systemd/system").is_dir() { + return Ok(()); + } + Err( + "this system does not run systemd, so the Nyx service cannot be installed — switch the \ + core permission mode to direct" + .into(), + ) +} + +pub fn install_here(uid: u32) -> Result<(), String> { + require_systemd()?; + + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let unit = UNIT_TEMPLATE + .replace("{exe}", &exe.display().to_string()) + .replace("{arg_host}", ARG_HOST) + .replace("{arg_owner}", ARG_OWNER) + .replace("{uid}", &uid.to_string()); + + remove_unit_files(); + + let mut errors = Vec::new(); + for dir in UNIT_DIRS.map(PathBuf::from) { + match write_unit(&dir, &unit) { + Ok(()) => { + logging::log(&format!("installed {UNIT_NAME} in {}", dir.display())); + if dir.starts_with("/run") { + logging::log( + "no persistent unit directory was writable — the service will not \ + survive a reboot", + ); + } + systemctl(&["daemon-reload"])?; + systemctl(&["restart", UNIT_NAME])?; + return Ok(()); + } + Err(e) => errors.push(e), + } + } + Err(errors.join("; ")) +} + +/// Writes the unit plus its `.wants` link, so it also starts at boot without +/// `systemctl enable`, which insists on `/etc`. +fn write_unit(dir: &Path, unit: &str) -> Result<(), String> { + std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + let path = dir.join(UNIT_NAME); + std::fs::write(&path, unit).map_err(|e| format!("cannot write {}: {e}", path.display()))?; + + let wants = dir.join(WANTS_DIR); + std::fs::create_dir_all(&wants) + .map_err(|e| format!("cannot create {}: {e}", wants.display()))?; + let link = wants.join(UNIT_NAME); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(&path, &link) + .map_err(|e| format!("cannot link {}: {e}", link.display())) +} + +pub fn uninstall_here() -> Result<(), String> { + let _ = systemctl(&["stop", UNIT_NAME]); + remove_unit_files(); + let _ = systemctl(&["daemon-reload"]); + Ok(()) +} + +fn remove_unit_files() { + for dir in UNIT_DIRS.map(PathBuf::from) { + let _ = std::fs::remove_file(dir.join(UNIT_NAME)); + let _ = std::fs::remove_file(dir.join(WANTS_DIR).join(UNIT_NAME)); + } +} + +const SYSTEMCTL_FALLBACKS: [&str; 4] = [ + "/run/current-system/sw/bin/systemctl", + "/usr/bin/systemctl", + "/bin/systemctl", + "/usr/sbin/systemctl", +]; + +fn systemctl_bin() -> PathBuf { + which("systemctl") + .or_else(|| { + SYSTEMCTL_FALLBACKS + .iter() + .map(PathBuf::from) + .find(|p| p.exists()) + }) + .unwrap_or_else(|| PathBuf::from("systemctl")) +} + +fn systemctl(args: &[&str]) -> Result<(), String> { + let out = Command::new(systemctl_bin()) + .args(args) + .output() + .map_err(|e| format!("cannot run systemctl: {e}"))?; + if out.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + Err(format!("systemctl {} failed: {stderr}", args.join(" "))) +} + +const POLKIT_AGENT_UNITS: [&str; 8] = [ + "hyprpolkitagent.service", + "polkit-gnome-authentication-agent-1.service", + "plasma-polkit-agent.service", + "polkit-kde-authentication-agent-1.service", + "lxqt-policykit-agent.service", + "xfce-polkit.service", + "mate-polkit.service", + "pantheon-agent-polkit.service", +]; + +const POLKIT_AGENT_BINARIES: [&str; 8] = [ + "hyprpolkitagent", + "polkit-gnome-authentication-agent-1", + "polkit-kde-authentication-agent-1", + "lxqt-policykit-agent", + "lxpolkit", + "polkit-mate-authentication-agent-1", + "xfce-polkit", + "pantheon-agent-polkit", +]; + +const AGENT_DIRS: [&str; 6] = [ + "/usr/libexec", + "/usr/lib/polkit-1", + "/usr/lib/polkit-gnome", + "/usr/lib/polkit-kde", + "/usr/local/libexec", + "/run/current-system/sw/libexec", +]; + +const SESSION_VARS: [&str; 5] = [ + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_TYPE", +]; + +const AGENT_SETTLE: Duration = Duration::from_millis(1200); + +static SPAWNED_AGENT: Mutex> = Mutex::new(None); + +enum Escalation { + NoAgent, + Dismissed, + Denied, + Failed(String), +} + +impl Escalation { + fn message(self, manual: &str) -> String { + match self { + Self::NoAgent => format!( + "no polkit authentication agent is running in this session, so the password \ + prompt cannot be shown. Start one (for example `systemctl --user start \ + hyprpolkitagent`, or enable it in your session config), or run `{manual}` in a \ + terminal." + ), + Self::Dismissed => "the authorisation request was cancelled".to_string(), + Self::Denied => format!( + "polkit refused the authorisation. Make sure your user is an administrator \ + (in the `wheel` or `sudo` group), or run `{manual}` in a terminal." + ), + Self::Failed(detail) => detail, + } + } +} + +/// pkexec blocks on a polkit prompt, so it never runs on an async worker. +async fn run_privileged(exe: &Path, args: &[&str]) -> Result<(), String> { + let manual = format!("sudo {} {}", exe.display(), args.join(" ")); + if which("pkexec").is_none() { + return Err(format!( + "pkexec is not installed, so Nyx cannot ask for administrator rights. Install \ + polkit, or run `{manual}` in a terminal." + )); + } + + match pkexec(exe, args).await { + Ok(()) => Ok(()), + Err(Escalation::NoAgent) => { + if !start_polkit_agent().await { + return Err(Escalation::NoAgent.message(&manual)); + } + match pkexec(exe, args).await { + Err(Escalation::NoAgent) => { + tokio::time::sleep(AGENT_SETTLE).await; + pkexec(exe, args).await.map_err(|e| e.message(&manual)) + } + other => other.map_err(|e| e.message(&manual)), + } + } + Err(e) => Err(e.message(&manual)), + } +} + +async fn pkexec(exe: &Path, args: &[&str]) -> Result<(), Escalation> { + let exe = exe.to_path_buf(); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let out = tokio::task::spawn_blocking(move || { + let mut cmd = Command::new("pkexec"); + cmd.arg(&exe).args(&args).stdin(Stdio::null()); + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + cmd.output() + }) + .await + .map_err(|e| Escalation::Failed(e.to_string()))? + .map_err(|e| Escalation::Failed(format!("cannot run pkexec: {e}")))?; + + if out.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + log::warn!( + "[service] pkexec exited with {:?}: {stderr}", + out.status.code() + ); + Err(match out.status.code() { + // The helper reports its own failures with a private code, so pkexec's + // 126/127 stay unambiguous. + Some(HELPER_FAILURE) if !stderr.is_empty() => Escalation::Failed(stderr), + Some(HELPER_FAILURE) => Escalation::Failed("the privileged helper failed".to_string()), + Some(126) => Escalation::Dismissed, + Some(127) if is_missing_agent(&stderr) => Escalation::NoAgent, + Some(127) if is_denied(&stderr) => Escalation::Denied, + _ if stderr.is_empty() => Escalation::Failed("the privileged helper failed".to_string()), + _ => Escalation::Failed(stderr), + }) +} + +fn is_missing_agent(stderr: &str) -> bool { + let s = stderr.to_ascii_lowercase(); + s.contains("authentication agent") || s.contains("/dev/tty") +} + +fn is_denied(stderr: &str) -> bool { + let s = stderr.to_ascii_lowercase(); + s.contains("not authorized") + || s.contains("incident has been reported") + || s.contains("authentication failed") +} + +/// Best effort: bring up an agent the machine already ships. Returns whether one +/// was started, so the caller only retries when something changed. +async fn start_polkit_agent() -> bool { + if spawned_agent_alive() { + return false; + } + let started = tokio::task::spawn_blocking(|| { + export_session_env(); + for unit in POLKIT_AGENT_UNITS { + let Some(exec) = user_unit_exec_start(unit) else { + continue; + }; + if systemctl_user(&["start", unit]).is_ok() && user_unit_active(unit) { + log::info!("[service] started polkit agent unit {unit}"); + return true; + } + if spawn_agent(&exec) { + return true; + } + } + POLKIT_AGENT_BINARIES + .iter() + .filter_map(|bin| find_agent_binary(bin)) + .any(|path| spawn_agent(&[path.display().to_string()])) + }) + .await + .unwrap_or(false); + + if started { + // The agent registers with polkitd asynchronously; pkexec run too soon + // still sees no agent. + tokio::time::sleep(AGENT_SETTLE).await; + } + started +} + +fn spawned_agent_alive() -> bool { + let Ok(mut guard) = SPAWNED_AGENT.lock() else { + return false; + }; + match guard.as_mut().map(|child| child.try_wait()) { + Some(Ok(None)) => true, + _ => { + *guard = None; + false + } + } +} + +fn spawn_agent(exec: &[String]) -> bool { + let Some((program, args)) = exec.split_first() else { + return false; + }; + match Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => { + log::info!("[service] spawned polkit agent {program}"); + if let Ok(mut guard) = SPAWNED_AGENT.lock() { + *guard = Some(child); + } + true + } + Err(e) => { + log::warn!("[service] could not spawn {program}: {e}"); + false + } + } +} + +/// The agent binary is often reachable only through its unit, so the unit doubles as a lookup table. +fn user_unit_exec_start(unit: &str) -> Option> { + let out = Command::new(systemctl_bin()) + .args(["--user", "cat", unit]) + .stderr(Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let line = text + .lines() + .map(str::trim) + .find_map(|l| l.strip_prefix("ExecStart="))?; + let words: Vec = line + .trim_start_matches(['-', '@', '+', '!', ':']) + .split_whitespace() + .map(String::from) + .collect(); + (!words.is_empty()).then_some(words) +} + +fn user_unit_active(unit: &str) -> bool { + Command::new(systemctl_bin()) + .args(["--user", "is-active", "--quiet", unit]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Compositors that never import their session into the user manager leave agent +/// units stuck on `ConditionEnvironment`. +fn export_session_env() { + let mut args = vec!["import-environment"]; + args.extend( + SESSION_VARS + .iter() + .copied() + .filter(|v| std::env::var_os(v).is_some()), + ); + if args.len() > 1 { + let _ = systemctl_user(&args); + } +} + +fn systemctl_user(args: &[&str]) -> Result<(), String> { + let out = Command::new(systemctl_bin()) + .arg("--user") + .args(args) + .output() + .map_err(|e| format!("cannot run systemctl --user: {e}"))?; + if out.status.success() { + return Ok(()); + } + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) +} + +fn find_agent_binary(bin: &str) -> Option { + which(bin).or_else(|| { + AGENT_DIRS + .iter() + .map(|dir| Path::new(dir).join(bin)) + .find(|p| p.is_file()) + }) +} + +fn which(bin: &str) -> Option { + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths) + .map(|dir| dir.join(bin)) + .find(|p| p.is_file()) + }) +} + +fn unit_exec_start() -> Option { + let text = UNIT_DIRS + .iter() + .find_map(|dir| std::fs::read_to_string(Path::new(dir).join(UNIT_NAME)).ok())?; + let line = text + .lines() + .map(str::trim) + .find_map(|l| l.strip_prefix("ExecStart="))?; + line.split_whitespace().next().map(PathBuf::from) +} + +fn unit_is_active() -> bool { + Command::new(systemctl_bin()) + .args(["is-active", "--quiet", UNIT_NAME]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Why the unit is not up, in one line, for error messages the user actually sees. +fn unit_diagnostics() -> String { + let out = Command::new(systemctl_bin()) + .args([ + "show", + "-p", + "ActiveState", + "-p", + "SubState", + "-p", + "Result", + "-p", + "ExecMainStatus", + UNIT_NAME, + ]) + .output(); + match out { + Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join(", "), + _ => "unit state unavailable".to_string(), + } +} + +fn owner_uid() -> u32 { + // Under pkexec the real user is in PKEXEC_UID; otherwise we are that user. + std::env::var("PKEXEC_UID") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or_else(|| unsafe { libc::getuid() }) +} + +async fn wait_for_socket(grace: Duration) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + grace; + loop { + if UnixStream::connect(SOCKET_PATH).await.is_ok() { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "the Nyx service did not open {SOCKET_PATH} in time ({})", + unit_diagnostics() + )); + } + tokio::time::sleep(Duration::from_millis(150)).await; + } +} + +async fn request(req: &Request) -> Result { + let mut stream = UnixStream::connect(SOCKET_PATH) + .await + .map_err(|e| format!("cannot reach the Nyx service: {e}"))?; + + let payload = serde_json::to_vec(req).map_err(|e| e.to_string())?; + stream + .write_all(&payload) + .await + .map_err(|e| format!("failed to send request: {e}"))?; + // The host reads one request per connection; half-close so it stops waiting. + let _ = stream.shutdown().await; + + let mut buf = Vec::new(); + stream + .read_to_end(&mut buf) + .await + .map_err(|e| format!("failed to read response: {e}"))?; + serde_json::from_slice(&buf).map_err(|e| format!("invalid service response: {e}")) +} diff --git a/crates/nyx-service/src/control/mod.rs b/crates/nyx-service/src/control/mod.rs new file mode 100644 index 0000000..f6db052 --- /dev/null +++ b/crates/nyx-service/src/control/mod.rs @@ -0,0 +1,92 @@ +#[cfg(target_os = "linux")] +pub(crate) mod linux; +#[cfg(windows)] +pub(crate) mod windows; + +#[cfg(target_os = "linux")] +use linux as imp; +#[cfg(windows)] +use windows as imp; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Status { + NotInstalled, + Stopped, + Running, + /// Installed, but pointing at a binary or protocol we can no longer talk to. + Stale { + reason: String, + }, +} + +impl Status { + pub fn is_running(&self) -> bool { + matches!(self, Status::Running) + } + + pub fn as_str(&self) -> &'static str { + match self { + Status::NotInstalled => "not-installed", + Status::Stopped => "stopped", + Status::Running => "running", + Status::Stale { .. } => "stale", + } + } +} + +#[cfg(any(target_os = "linux", windows))] +pub use imp::{ + install, ping, restart_service, start_core, start_service, status, stop_core, stop_service, + uninstall, +}; + +/// Whether the unit is owned by the OS rather than by Nyx. +#[cfg(target_os = "linux")] +pub fn is_managed() -> bool { + linux::is_managed() +} + +#[cfg(not(target_os = "linux"))] +pub fn is_managed() -> bool { + false +} + +#[cfg(not(any(target_os = "linux", windows)))] +mod unsupported { + use super::Status; + use crate::protocol::CoreSpec; + + pub async fn status() -> Result { + Ok(Status::NotInstalled) + } + pub async fn install() -> Result<(), String> { + Err("service mode is not supported on this platform".into()) + } + pub async fn uninstall() -> Result<(), String> { + Ok(()) + } + pub async fn start_core(_spec: &CoreSpec) -> Result { + Err("service mode is not supported on this platform".into()) + } + pub async fn stop_core() -> Result<(), String> { + Ok(()) + } + pub async fn ping() -> Result, String> { + Err("service mode is not supported on this platform".into()) + } + pub async fn start_service() -> Result<(), String> { + Err("service mode is not supported on this platform".into()) + } + pub async fn stop_service() -> Result<(), String> { + Ok(()) + } + pub async fn restart_service() -> Result<(), String> { + Err("service mode is not supported on this platform".into()) + } +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub use unsupported::{ + install, ping, restart_service, start_core, start_service, status, stop_core, stop_service, + uninstall, +}; diff --git a/crates/nyx-service/src/control/windows.rs b/crates/nyx-service/src/control/windows.rs new file mode 100644 index 0000000..197caab --- /dev/null +++ b/crates/nyx-service/src/control/windows.rs @@ -0,0 +1,427 @@ +use std::ffi::OsString; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::windows::named_pipe::ClientOptions; +use windows_service::service::{ + ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceState, ServiceType, +}; +use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; + +use crate::control::Status; +use crate::host::windows::PIPE_NAME; +use crate::protocol::{CoreSpec, PROTOCOL_VERSION, Request, Response}; +use crate::{ + ARG_CONTROL, ARG_HOST, ARG_INSTALL, ARG_OWNER, ARG_UNINSTALL, SERVICE_DISPLAY_NAME, + SERVICE_NAME, is_elevated, +}; + +const ERR_NO_SERVICE: i32 = 1060; +const CONNECT_GRACE: Duration = Duration::from_secs(10); +const STATE_TIMEOUT: Duration = Duration::from_secs(15); + +pub async fn status() -> Result { + let Some(state) = query_state()? else { + return Ok(Status::NotInstalled); + }; + if state != ServiceState::Running { + return Ok(Status::Stopped); + } + match ping().await { + Ok(_) => Ok(Status::Running), + Err(reason) => Ok(Status::Stale { reason }), + } +} + +pub async fn install() -> Result<(), String> { + if is_elevated() { + install_here()?; + } else { + elevate(&[ARG_INSTALL, ARG_OWNER, ¤t_user_sid()])?; + } + wait_for_state(true).await?; + ping().await.map(|_| ()) +} + +pub async fn uninstall() -> Result<(), String> { + if query_state()?.is_none() { + return Ok(()); + } + let _ = stop_core().await; + if is_elevated() { + uninstall_here()?; + } else { + elevate(&[ARG_UNINSTALL])?; + } + Ok(()) +} + +pub async fn start_service() -> Result<(), String> { + control("start")?; + wait_for_state(true).await?; + ping().await.map(|_| ()) +} + +pub async fn stop_service() -> Result<(), String> { + if query_state()?.is_none() { + return Ok(()); + } + control("stop")?; + wait_for_state(false).await +} + +pub async fn restart_service() -> Result<(), String> { + control("restart")?; + wait_for_state(true).await?; + ping().await.map(|_| ()) +} + +fn control(action: &'static str) -> Result<(), String> { + if query_state()?.is_none() { + return Err("the Nyx service is not installed".into()); + } + if is_elevated() { + return control_here(action); + } + elevate(&[ARG_CONTROL, action]) +} + +pub fn control_here(action: &str) -> Result<(), String> { + let manager = open_manager(ServiceManagerAccess::CONNECT)?; + let service = manager + .open_service( + SERVICE_NAME, + ServiceAccess::START | ServiceAccess::STOP | ServiceAccess::QUERY_STATUS, + ) + .map_err(|e| format!("failed to open service: {e}"))?; + + let stop = |service: &windows_service::service::Service| -> Result<(), String> { + let running = service + .query_status() + .map(|s| s.current_state != ServiceState::Stopped) + .unwrap_or(false); + if !running { + return Ok(()); + } + service + .stop() + .map(|_| ()) + .map_err(|e| format!("failed to stop service: {e}")) + }; + + match action { + "start" => start_if_stopped(&service), + "stop" => stop(&service), + "restart" => { + stop(&service)?; + wait_for_stopped_blocking(&service); + start_if_stopped(&service) + } + other => Err(format!("unknown service action {other:?}")), + } +} + +/// The elevated helper is a plain process, so the wait stays synchronous. +fn wait_for_stopped_blocking(service: &windows_service::service::Service) { + for _ in 0..50 { + match service.query_status() { + Ok(s) if s.current_state == ServiceState::Stopped => return, + Err(_) => return, + _ => std::thread::sleep(Duration::from_millis(200)), + } + } +} + +pub async fn start_core(spec: &CoreSpec) -> Result { + match status().await? { + Status::NotInstalled => return Err("the Nyx service is not installed".into()), + Status::Running => {} + _ => install().await?, + } + match request(&Request::StartCore(spec.clone()), CONNECT_GRACE).await? { + Response::Started { pid } => Ok(pid), + Response::Error { message } => Err(message), + other => Err(format!("unexpected service response: {other:?}")), + } +} + +pub async fn stop_core() -> Result<(), String> { + if query_state()? != Some(ServiceState::Running) { + return Ok(()); + } + match request(&Request::StopCore, Duration::ZERO).await? { + Response::Ok => Ok(()), + Response::Error { message } => Err(message), + other => Err(format!("unexpected service response: {other:?}")), + } +} + +/// `Ok(core_pid)` — `None` means the host is up but no core is running. +pub async fn ping() -> Result, String> { + match request(&Request::Ping, Duration::ZERO).await? { + Response::Pong { + protocol_version, + core_pid, + } if protocol_version == PROTOCOL_VERSION => Ok(core_pid), + Response::Pong { + protocol_version, .. + } => Err(format!( + "service speaks protocol v{protocol_version}, this build expects v{PROTOCOL_VERSION}" + )), + Response::Error { message } => Err(message), + other => Err(format!("unexpected service response: {other:?}")), + } +} + +pub fn install_here() -> Result<(), String> { + let manager = + open_manager(ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE)?; + let info = service_info(); + + match manager.open_service( + SERVICE_NAME, + ServiceAccess::CHANGE_CONFIG | ServiceAccess::START | ServiceAccess::QUERY_STATUS, + ) { + Ok(service) => { + service + .change_config(&info) + .map_err(|e| format!("failed to reconfigure service: {e}"))?; + start_if_stopped(&service) + } + Err(e) if is_missing_service(&e) => { + let service = manager + .create_service(&info, ServiceAccess::START | ServiceAccess::QUERY_STATUS) + .map_err(|e| format!("failed to create service: {e}"))?; + let _ = service.set_description("Runs the mihomo core for Nyx"); + start_if_stopped(&service) + } + Err(e) => Err(format!("failed to open service: {e}")), + } +} + +pub fn uninstall_here() -> Result<(), String> { + let manager = open_manager(ServiceManagerAccess::CONNECT)?; + let service = match manager.open_service( + SERVICE_NAME, + ServiceAccess::STOP | ServiceAccess::DELETE | ServiceAccess::QUERY_STATUS, + ) { + Ok(s) => s, + Err(e) if is_missing_service(&e) => return Ok(()), + Err(e) => return Err(format!("failed to open service: {e}")), + }; + if let Ok(status) = service.query_status() + && status.current_state != ServiceState::Stopped + { + let _ = service.stop(); + } + service + .delete() + .map_err(|e| format!("failed to delete service: {e}")) +} + +fn service_info() -> ServiceInfo { + ServiceInfo { + name: OsString::from(SERVICE_NAME), + display_name: OsString::from(SERVICE_DISPLAY_NAME), + service_type: ServiceType::OWN_PROCESS, + start_type: ServiceStartType::AutoStart, + error_control: ServiceErrorControl::Normal, + executable_path: std::env::current_exe().unwrap_or_default(), + launch_arguments: vec![ + OsString::from(ARG_HOST), + OsString::from(ARG_OWNER), + OsString::from(crate::owner_arg().unwrap_or_else(current_user_sid)), + ], + dependencies: vec![], + account_name: None, + account_password: None, + } +} + +fn start_if_stopped(service: &windows_service::service::Service) -> Result<(), String> { + let running = service + .query_status() + .map(|s| s.current_state == ServiceState::Running) + .unwrap_or(false); + if running { + return Ok(()); + } + service + .start(&[] as &[&std::ffi::OsStr]) + .map_err(|e| format!("failed to start service: {e}")) +} + +fn open_manager(access: ServiceManagerAccess) -> Result { + ServiceManager::local_computer(None::<&str>, access) + .map_err(|e| format!("failed to open service manager: {e}")) +} + +fn is_missing_service(e: &windows_service::Error) -> bool { + matches!(e, windows_service::Error::Winapi(io) + if io.raw_os_error() == Some(ERR_NO_SERVICE)) +} + +fn query_state() -> Result, String> { + let manager = open_manager(ServiceManagerAccess::CONNECT)?; + let service = match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) { + Ok(s) => s, + Err(e) if is_missing_service(&e) => return Ok(None), + Err(e) => return Err(format!("failed to open service: {e}")), + }; + let status = service + .query_status() + .map_err(|e| format!("failed to query service status: {e}"))?; + Ok(Some(status.current_state)) +} + +async fn wait_for_state(want_running: bool) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + STATE_TIMEOUT; + loop { + let running = matches!(query_state(), Ok(Some(ServiceState::Running))); + if running == want_running { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "the service did not reach the {} state in time", + if want_running { "running" } else { "stopped" } + )); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +async fn request(req: &Request, connect_grace: Duration) -> Result { + let deadline = tokio::time::Instant::now() + connect_grace; + let mut client = loop { + match ClientOptions::new().open(PIPE_NAME) { + Ok(c) => break c, + // ERROR_FILE_NOT_FOUND / ERROR_PIPE_BUSY: the host is still coming up. + Err(e) + if tokio::time::Instant::now() < deadline + && matches!(e.raw_os_error(), Some(2) | Some(231)) => + { + tokio::time::sleep(Duration::from_millis(150)).await; + } + Err(e) => return Err(format!("cannot reach the Nyx service: {e}")), + } + }; + + let payload = serde_json::to_vec(req).map_err(|e| e.to_string())?; + client + .write_all(&payload) + .await + .map_err(|e| format!("failed to send request: {e}"))?; + + let mut buf = vec![0u8; 8192]; + let n = client + .read(&mut buf) + .await + .map_err(|e| format!("failed to read response: {e}"))?; + serde_json::from_slice(&buf[..n]).map_err(|e| format!("invalid service response: {e}")) +} + +fn elevate(args: &[&str]) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let arg_list = args + .iter() + .map(|a| format!("'{}'", a.replace('\'', "''"))) + .collect::>() + .join(","); + let script = format!( + "$p = Start-Process -FilePath '{}' -ArgumentList {} -Verb RunAs -WindowStyle Hidden -PassThru -Wait; exit $p.ExitCode", + exe.display().to_string().replace('\'', "''"), + arg_list + ); + + use std::os::windows::process::CommandExt; + let out = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + &script, + ]) + .creation_flags(0x08000000) + .output() + .map_err(|e| format!("failed to request elevation: {e}"))?; + + if out.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + Err(if stderr.is_empty() { + "the elevation prompt was cancelled".to_string() + } else { + stderr + }) +} + +/// SID of the current user, used to lock the IPC pipe to them. +fn current_user_sid() -> String { + unsafe { current_user_sid_raw() }.unwrap_or_else(|| "IU".to_string()) +} + +unsafe fn current_user_sid_raw() -> Option { + use std::ptr; + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken( + process: *mut std::ffi::c_void, + desired_access: u32, + token_handle: *mut *mut std::ffi::c_void, + ) -> i32; + fn GetTokenInformation( + token_handle: *mut std::ffi::c_void, + token_information_class: u32, + token_information: *mut std::ffi::c_void, + token_information_length: u32, + return_length: *mut u32, + ) -> i32; + fn ConvertSidToStringSidW(sid: *mut std::ffi::c_void, string_sid: *mut *mut u16) -> i32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> *mut std::ffi::c_void; + fn CloseHandle(handle: *mut std::ffi::c_void) -> i32; + fn LocalFree(mem: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + } + + const TOKEN_QUERY: u32 = 0x0008; + const TOKEN_USER_CLASS: u32 = 1; + + unsafe { + let mut token: *mut std::ffi::c_void = ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return None; + } + + let mut size: u32 = 0; + GetTokenInformation(token, TOKEN_USER_CLASS, ptr::null_mut(), 0, &mut size); + let mut buffer = vec![0u8; size as usize]; + let ok = GetTokenInformation( + token, + TOKEN_USER_CLASS, + buffer.as_mut_ptr() as *mut std::ffi::c_void, + size, + &mut size, + ); + CloseHandle(token); + if ok == 0 || buffer.len() < std::mem::size_of::<*mut std::ffi::c_void>() { + return None; + } + + let sid = *(buffer.as_ptr() as *const *mut std::ffi::c_void); + let mut raw: *mut u16 = ptr::null_mut(); + if ConvertSidToStringSidW(sid, &mut raw) == 0 || raw.is_null() { + return None; + } + let len = (0..).take_while(|&i| *raw.add(i) != 0).count(); + let text = String::from_utf16_lossy(std::slice::from_raw_parts(raw, len)); + LocalFree(raw as *mut std::ffi::c_void); + Some(text) + } +} diff --git a/crates/nyx-service/src/host/linux.rs b/crates/nyx-service/src/host/linux.rs new file mode 100644 index 0000000..59d34f3 --- /dev/null +++ b/crates/nyx-service/src/host/linux.rs @@ -0,0 +1,150 @@ +use std::os::unix::io::AsRawFd; +use std::path::Path; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{UnixListener, UnixStream}; + +use crate::host::{CoreManager, decode, encode}; +use crate::logging; +use crate::protocol::{Request, Response}; + +pub const SOCKET_PATH: &str = "/run/nyx/nyx.sock"; + +const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); + +pub fn run_host(owner_uid: u32) -> i32 { + let rt = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + logging::log(&format!("failed to build tokio runtime: {e}")); + return 1; + } + }; + rt.block_on(async move { + match serve(owner_uid).await { + Ok(()) => 0, + Err(e) => { + logging::log(&format!("host error: {e}")); + 1 + } + } + }) +} + +async fn serve(owner_uid: u32) -> Result<(), String> { + let path = Path::new(SOCKET_PATH); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {parent:?}: {e}"))?; + } + // A leftover socket from a hard kill would make bind fail with EADDRINUSE. + let _ = std::fs::remove_file(path); + + let listener = + UnixListener::bind(path).map_err(|e| format!("cannot bind {SOCKET_PATH}: {e}"))?; + restrict_socket(path, owner_uid); + logging::log(&format!( + "host listening on {SOCKET_PATH} for uid {owner_uid}" + )); + + let mut manager = CoreManager::default(); + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(|e| format!("cannot install SIGTERM handler: {e}"))?; + let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .map_err(|e| format!("cannot install SIGINT handler: {e}"))?; + + loop { + tokio::select! { + _ = term.recv() => break, + _ = int.recv() => break, + accepted = listener.accept() => { + let Ok((mut stream, _)) = accepted else { continue }; + match peer_uid(&stream) { + Some(uid) if uid == owner_uid || uid == 0 => {} + Some(uid) => { + logging::log(&format!("rejected connection from uid {uid}")); + continue; + } + None => { + logging::log("rejected connection with unknown peer credentials"); + continue; + } + } + let _ = tokio::time::timeout( + CLIENT_TIMEOUT, + serve_connection(&mut stream, &mut manager), + ) + .await; + } + } + } + + logging::log("host stopping"); + manager.handle(Request::StopCore).await; + let _ = std::fs::remove_file(path); + Ok(()) +} + +async fn serve_connection(stream: &mut UnixStream, manager: &mut CoreManager) { + let mut buf = vec![0u8; 8192]; + let n = match stream.read(&mut buf).await { + Ok(n) if n > 0 => n, + _ => return, + }; + let response = match decode(&buf[..n]) { + Ok(req) => manager.handle(req).await, + Err(message) => Response::Error { message }, + }; + let _ = stream.write_all(&encode(&response)).await; + let _ = stream.shutdown().await; +} + +fn restrict_socket(path: &Path, owner_uid: u32) { + use std::os::unix::ffi::OsStrExt; + let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return; + }; + if unsafe { libc::chmod(c_path.as_ptr(), 0o600) } != 0 { + logging::log(&format!( + "cannot chmod {SOCKET_PATH}: {}", + std::io::Error::last_os_error() + )); + } + // gid -1 leaves the group alone. + if unsafe { libc::chown(c_path.as_ptr(), owner_uid, u32::MAX) } == 0 { + return; + } + logging::log(&format!( + "cannot chown {SOCKET_PATH} to uid {owner_uid} ({}); falling back to peer-credential \ + checks only", + std::io::Error::last_os_error() + )); + if unsafe { libc::chmod(c_path.as_ptr(), 0o666) } != 0 { + logging::log(&format!( + "cannot chmod {SOCKET_PATH}: {}", + std::io::Error::last_os_error() + )); + } +} + +fn peer_uid(stream: &UnixStream) -> Option { + let mut cred = libc::ucred { + pid: 0, + uid: u32::MAX, + gid: u32::MAX, + }; + let mut len = std::mem::size_of::() as libc::socklen_t; + let ok = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut cred as *mut libc::ucred as *mut libc::c_void, + &mut len, + ) + }; + (ok == 0).then_some(cred.uid) +} diff --git a/crates/nyx-service/src/host/mod.rs b/crates/nyx-service/src/host/mod.rs new file mode 100644 index 0000000..1d3d28f --- /dev/null +++ b/crates/nyx-service/src/host/mod.rs @@ -0,0 +1,149 @@ +#[cfg(unix)] +pub mod linux; +#[cfg(windows)] +pub mod windows; + +use std::process::Stdio; +use std::time::Duration; + +use tokio::process::Child; + +use crate::logging; +use crate::protocol::{CoreSpec, PROTOCOL_VERSION, Request, Response}; + +/// How long a freshly spawned core is watched before reporting success — a bad +/// binary or config dies well inside this window, so the app gets a real error. +const SETTLE: Duration = Duration::from_millis(400); + +/// Grace period for the core to exit on SIGTERM before it is killed. +const STOP_GRACE: Duration = Duration::from_secs(3); + +#[derive(Default)] +pub struct CoreManager { + child: Option, +} + +impl CoreManager { + pub async fn handle(&mut self, req: Request) -> Response { + match req { + Request::StartCore(spec) => match self.start(spec).await { + Ok(pid) => Response::Started { pid }, + Err(message) => { + logging::log(&format!("start failed: {message}")); + Response::Error { message } + } + }, + Request::StopCore => { + self.stop().await; + Response::Ok + } + Request::Ping => Response::Pong { + protocol_version: PROTOCOL_VERSION, + core_pid: self.live_pid(), + }, + } + } + + async fn start(&mut self, spec: CoreSpec) -> Result { + logging::clean_old(spec.max_log_days); + self.stop().await; + + if !spec.binary.exists() { + return Err(format!("core binary not found: {}", spec.binary.display())); + } + if !spec.config.exists() { + return Err(format!("config not found: {}", spec.config.display())); + } + + let mut cmd = tokio::process::Command::new(&spec.binary); + cmd.arg("-d") + .arg(&spec.work_dir) + .arg("-f") + .arg(&spec.config) + .stdin(Stdio::null()) + .stdout(core_output()) + .stderr(core_output()) + .kill_on_drop(true); + + #[cfg(windows)] + cmd.creation_flags(0x08000000); + + let mut child = cmd + .spawn() + .map_err(|e| format!("failed to spawn {}: {e}", spec.binary.display()))?; + let pid = child.id().unwrap_or(0); + + tokio::time::sleep(SETTLE).await; + if let Ok(Some(status)) = child.try_wait() { + return Err(format!( + "core exited immediately ({status}); see {}", + logging::log_dir().join("mihomo.log").display() + )); + } + + logging::log(&format!("started core pid={pid} config={:?}", spec.config)); + self.child = Some(child); + Ok(pid) + } + + async fn stop(&mut self) { + let Some(mut child) = self.child.take() else { + return; + }; + let pid = child.id().unwrap_or(0); + request_termination(&mut child, pid); + + let deadline = tokio::time::Instant::now() + STOP_GRACE; + loop { + match child.try_wait() { + Ok(Some(_)) | Err(_) => break, + Ok(None) if tokio::time::Instant::now() >= deadline => { + let _ = child.kill().await; + let _ = child.wait().await; + break; + } + Ok(None) => tokio::time::sleep(Duration::from_millis(50)).await, + } + } + logging::log(&format!("stopped core pid={pid}")); + } + + fn live_pid(&mut self) -> Option { + let child = self.child.as_mut()?; + match child.try_wait() { + Ok(None) => child.id(), + _ => { + self.child = None; + None + } + } + } +} + +fn core_output() -> Stdio { + let dir = logging::log_dir(); + let _ = std::fs::create_dir_all(&dir); + std::fs::File::create(dir.join("mihomo.log")) + .map(Stdio::from) + .unwrap_or_else(|_| Stdio::null()) +} + +#[cfg(unix)] +fn request_termination(_child: &mut Child, pid: u32) { + if pid > 0 { + unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + } +} + +#[cfg(not(unix))] +fn request_termination(child: &mut Child, _pid: u32) { + let _ = child.start_kill(); +} + +pub fn encode(res: &Response) -> Vec { + serde_json::to_vec(res).unwrap_or_else(|_| br#"{"status":"ERROR","message":"encode"}"#.to_vec()) +} + +pub fn decode(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| format!("invalid request: {e}")) +} diff --git a/crates/nyx-service/src/host/windows.rs b/crates/nyx-service/src/host/windows.rs new file mode 100644 index 0000000..e230f5e --- /dev/null +++ b/crates/nyx-service/src/host/windows.rs @@ -0,0 +1,196 @@ +use std::ffi::{OsString, c_void}; +use std::sync::mpsc; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; +use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; +use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; +use windows::core::{BOOL, PCWSTR}; +use windows_service::define_windows_service; +use windows_service::service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType, +}; +use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; +use windows_service::service_dispatcher; + +use crate::host::{CoreManager, decode, encode}; +use crate::protocol::{Request, Response}; +use crate::{ARG_OWNER, SERVICE_NAME, logging}; + +pub const PIPE_NAME: &str = r"\\.\pipe\nyx_mihomo_ipc"; + +const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); + +define_windows_service!(ffi_service_main, service_main); + +pub fn run_dispatcher() -> i32 { + match service_dispatcher::start(SERVICE_NAME, ffi_service_main) { + Ok(()) => 0, + Err(e) => { + logging::log(&format!("service dispatcher failed: {e}")); + 1 + } + } +} + +fn service_main(_arguments: Vec) { + if let Err(e) = run_service() { + logging::log(&format!("service runtime error: {e}")); + } +} + +fn run_service() -> windows_service::Result<()> { + logging::log("host starting"); + let (stop_tx, stop_rx) = mpsc::channel::<()>(); + + let status_handle = + service_control_handler::register(SERVICE_NAME, move |event| match event { + ServiceControl::Stop => { + let _ = stop_tx.send(()); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + })?; + + status_handle.set_service_status(status(ServiceState::Running, ServiceControlAccept::STOP))?; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime"); + + rt.block_on(async move { + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + std::thread::spawn(move || { + let _ = stop_rx.recv(); + let _ = shutdown_tx.send(()); + }); + serve(shutdown_rx).await; + }); + + status_handle + .set_service_status(status(ServiceState::Stopped, ServiceControlAccept::empty()))?; + logging::log("host stopped"); + Ok(()) +} + +fn status(state: ServiceState, accepted: ServiceControlAccept) -> ServiceStatus { + ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: state, + controls_accepted: accepted, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::from_secs(0), + process_id: None, + } +} + +async fn serve(mut shutdown: tokio::sync::oneshot::Receiver<()>) { + let mut manager = CoreManager::default(); + let mut security = pipe_security(); + + let mut server = match create_pipe(security.as_mut(), true) { + Ok(s) => s, + Err(e) => { + logging::log(&format!("cannot create pipe: {e}")); + return; + } + }; + + loop { + tokio::select! { + _ = &mut shutdown => break, + connected = server.connect() => { + if let Err(e) = connected { + logging::log(&format!("pipe connect error: {e}")); + continue; + } + // Queue the next instance before serving, so a fast reconnect never misses it. + let next = match create_pipe(security.as_mut(), false) { + Ok(s) => s, + Err(e) => { + logging::log(&format!("cannot create pipe instance: {e}")); + break; + } + }; + let mut conn = std::mem::replace(&mut server, next); + let _ = tokio::time::timeout( + CLIENT_TIMEOUT, + serve_connection(&mut conn, &mut manager), + ) + .await; + conn.disconnect().ok(); + } + } + } + + manager.handle(Request::StopCore).await; +} + +async fn serve_connection(conn: &mut NamedPipeServer, manager: &mut CoreManager) { + let mut buf = vec![0u8; 8192]; + let n = match conn.read(&mut buf).await { + Ok(n) if n > 0 => n, + _ => return, + }; + let response = match decode(&buf[..n]) { + Ok(req) => manager.handle(req).await, + Err(message) => Response::Error { message }, + }; + let _ = conn.write_all(&encode(&response)).await; + let _ = conn.flush().await; +} + +fn create_pipe( + security: Option<&mut SECURITY_ATTRIBUTES>, + first: bool, +) -> std::io::Result { + let mut opts = ServerOptions::new(); + opts.first_pipe_instance(first); + match security { + Some(sa) => unsafe { + opts.create_with_security_attributes_raw( + PIPE_NAME, + sa as *mut SECURITY_ATTRIBUTES as *mut c_void, + ) + }, + None => opts.create(PIPE_NAME), + } +} + +/// The pipe hands a SYSTEM process a binary path to run, so it is limited to +/// SYSTEM, administrators, and the user the service was installed for. +fn pipe_security() -> Option { + let owner = owner_sid_arg().unwrap_or_else(|| "IU".to_string()); + let sddl: Vec = format!("D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGWGX;;;{owner})\0") + .encode_utf16() + .collect(); + let mut psd = PSECURITY_DESCRIPTOR::default(); + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR::from_raw(sddl.as_ptr()), + 1, + &mut psd as *mut _, + None, + ) + .ok()?; + } + Some(SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: psd.0, + bInheritHandle: BOOL(0), + }) +} + +fn owner_sid_arg() -> Option { + let mut args = std::env::args(); + while let Some(arg) = args.next() { + if arg == ARG_OWNER { + return args.next().filter(|s| !s.is_empty()); + } + } + None +} diff --git a/crates/nyx-service/src/lib.rs b/crates/nyx-service/src/lib.rs new file mode 100644 index 0000000..d3e8543 --- /dev/null +++ b/crates/nyx-service/src/lib.rs @@ -0,0 +1,218 @@ +mod control; +mod host; +mod logging; +mod protocol; + +pub use control::{ + Status, install, is_managed, ping, restart_service, start_core, start_service, status, + stop_core, stop_service, uninstall, +}; +pub use protocol::{CoreSpec, PROTOCOL_VERSION}; + +pub const SERVICE_NAME: &str = "Nyx Service"; +pub const SERVICE_DISPLAY_NAME: &str = "Nyx Mihomo Service"; + +pub(crate) const ARG_HOST: &str = "--nyx-service"; +pub(crate) const ARG_INSTALL: &str = "--nyx-service-install"; +pub(crate) const ARG_UNINSTALL: &str = "--nyx-service-uninstall"; +pub(crate) const ARG_CONTROL: &str = "--nyx-service-control"; +pub(crate) const ARG_OWNER: &str = "--nyx-service-owner"; + +/// "prompt dismissed" and "authorisation refused". +pub(crate) const HELPER_FAILURE: i32 = 9; + +/// Handles the argv modes that must run before any GUI work: the service host +/// and the two elevated install/uninstall entry points. +pub fn maybe_run_service_mode() -> Option { + let args: Vec = std::env::args().collect(); + let has = |flag: &str| args.iter().any(|a| a == flag); + + if has(ARG_HOST) { + return Some(run_host()); + } + if has(ARG_INSTALL) { + return Some(report(install_here())); + } + if has(ARG_UNINSTALL) { + return Some(report(uninstall_here())); + } + if has(ARG_CONTROL) { + return Some(report(control_here( + &flag_value(ARG_CONTROL).unwrap_or_default(), + ))); + } + None +} + +fn report(result: Result<(), String>) -> i32 { + match result { + Ok(()) => 0, + Err(e) => { + logging::log(&format!("privileged helper failed: {e}")); + eprintln!("{e}"); + HELPER_FAILURE + } + } +} + +#[cfg(windows)] +fn run_host() -> i32 { + host::windows::run_dispatcher() +} + +#[cfg(target_os = "linux")] +fn run_host() -> i32 { + let owner = match owner_arg() { + Some(value) => resolve_uid(&value).unwrap_or_else(|| { + logging::log(&format!( + "unknown service owner {value:?} — only root may drive the service" + )); + 0 + }), + None => 0, + }; + host::linux::run_host(owner) +} + +/// The owner arrives as a uid from Nyx's own installer, but as a user name from +/// a declarative unit, where the uid is not known at build time. +#[cfg(target_os = "linux")] +fn resolve_uid(value: &str) -> Option { + if let Ok(uid) = value.parse::() { + return Some(uid); + } + let name = std::ffi::CString::new(value).ok()?; + // getpwnam's static buffer is safe here: this runs once, before any threads. + let pw = unsafe { libc::getpwnam(name.as_ptr()) }; + (!pw.is_null()).then(|| unsafe { (*pw).pw_uid }) +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn run_host() -> i32 { + 1 +} + +#[cfg(windows)] +fn install_here() -> Result<(), String> { + control::windows::install_here() +} + +#[cfg(target_os = "linux")] +fn install_here() -> Result<(), String> { + let uid = owner_arg() + .and_then(|s| resolve_uid(&s)) + .or_else(|| { + std::env::var("PKEXEC_UID") + .ok() + .and_then(|v| v.parse().ok()) + }) + .unwrap_or_else(|| unsafe { libc::getuid() }); + control::linux::install_here(uid) +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn install_here() -> Result<(), String> { + Err("service mode is not supported on this platform".into()) +} + +#[cfg(windows)] +fn uninstall_here() -> Result<(), String> { + control::windows::uninstall_here() +} + +#[cfg(target_os = "linux")] +fn uninstall_here() -> Result<(), String> { + control::linux::uninstall_here() +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn uninstall_here() -> Result<(), String> { + Ok(()) +} + +#[cfg(windows)] +fn control_here(action: &str) -> Result<(), String> { + control::windows::control_here(action) +} + +#[cfg(target_os = "linux")] +fn control_here(action: &str) -> Result<(), String> { + control::linux::control_here(action) +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn control_here(_action: &str) -> Result<(), String> { + Err("service mode is not supported on this platform".into()) +} + +fn flag_value(flag: &str) -> Option { + let mut args = std::env::args(); + while let Some(arg) = args.next() { + if arg == flag { + return args.next().filter(|s| !s.is_empty()); + } + } + None +} + +#[cfg(any(windows, target_os = "linux"))] +pub(crate) fn owner_arg() -> Option { + flag_value(ARG_OWNER) +} + +#[cfg(windows)] +pub fn is_elevated() -> bool { + use std::mem; + use std::ptr; + unsafe { + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken( + process: *mut std::ffi::c_void, + desired_access: u32, + token_handle: *mut *mut std::ffi::c_void, + ) -> i32; + fn GetTokenInformation( + token_handle: *mut std::ffi::c_void, + token_information_class: u32, + token_information: *mut std::ffi::c_void, + token_information_length: u32, + return_length: *mut u32, + ) -> i32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> *mut std::ffi::c_void; + fn CloseHandle(handle: *mut std::ffi::c_void) -> i32; + } + + const TOKEN_QUERY: u32 = 0x0008; + const TOKEN_ELEVATION: u32 = 20; + + let mut token: *mut std::ffi::c_void = ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return false; + } + + #[repr(C)] + struct TokenElevation { + token_is_elevated: u32, + } + let mut elevation: TokenElevation = mem::zeroed(); + let mut size: u32 = 0; + let ok = GetTokenInformation( + token, + TOKEN_ELEVATION, + &mut elevation as *mut _ as *mut std::ffi::c_void, + mem::size_of::() as u32, + &mut size, + ); + CloseHandle(token); + ok != 0 && elevation.token_is_elevated != 0 + } +} + +#[cfg(not(windows))] +pub fn is_elevated() -> bool { + unsafe { libc::geteuid() == 0 } +} diff --git a/crates/nyx-service/src/logging.rs b/crates/nyx-service/src/logging.rs new file mode 100644 index 0000000..428b015 --- /dev/null +++ b/crates/nyx-service/src/logging.rs @@ -0,0 +1,59 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// A system service cannot reach the GUI user's data dir, so the host logs here. +pub fn log_dir() -> PathBuf { + #[cfg(windows)] + { + PathBuf::from("C:\\ProgramData\\Nyx") + } + #[cfg(not(windows))] + { + PathBuf::from("/var/log/nyx") + } +} + +pub fn log(msg: &str) { + let dir = log_dir(); + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + let _ = std::fs::create_dir_all(&dir); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join(format!("{today}.log"))) + { + let ts = chrono::Local::now().format("%H:%M:%S%.3f"); + let _ = writeln!(f, "[{ts}] {msg}"); + } +} + +pub fn clean_old(max_days: u32) { + if max_days == 0 { + return; + } + let Some(cutoff) = chrono::Local::now() + .date_naive() + .checked_sub_signed(chrono::Duration::days(max_days as i64 - 1)) + else { + return; + }; + let Ok(entries) = std::fs::read_dir(log_dir()) else { + return; + }; + for path in entries.filter_map(|e| e.ok()).map(|e| e.path()) { + if is_expired_log(&path, cutoff) { + let _ = std::fs::remove_file(&path); + } + } +} + +fn is_expired_log(path: &Path, cutoff: chrono::NaiveDate) -> bool { + if path.extension().and_then(|s| s.to_str()) != Some("log") { + return false; + } + path.file_stem() + .and_then(|s| s.to_str()) + .and_then(|stem| chrono::NaiveDate::parse_from_str(stem, "%Y-%m-%d").ok()) + .map(|date| date < cutoff) + .unwrap_or(false) +} diff --git a/crates/nyx-service/src/protocol.rs b/crates/nyx-service/src/protocol.rs new file mode 100644 index 0000000..b92c039 --- /dev/null +++ b/crates/nyx-service/src/protocol.rs @@ -0,0 +1,45 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Bumped on any wire-format or host-behaviour change; a mismatch in `Pong` +/// tells the app to reinstall the service. +pub const PROTOCOL_VERSION: u32 = 2; + +/// Everything the host needs to launch a core — the app resolves every path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct CoreSpec { + pub binary: PathBuf, + pub work_dir: PathBuf, + pub config: PathBuf, + #[serde(default = "default_max_log_days")] + pub max_log_days: u32, +} + +fn default_max_log_days() -> u32 { + 7 +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "action", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Request { + StartCore(CoreSpec), + StopCore, + Ping, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "status", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Response { + Started { + pid: u32, + }, + Ok, + Pong { + protocol_version: u32, + core_pid: Option, + }, + Error { + message: String, + }, +} diff --git a/crates/nyx-sysproxy/Cargo.toml b/crates/nyx-sysproxy/Cargo.toml new file mode 100644 index 0000000..60f433b --- /dev/null +++ b/crates/nyx-sysproxy/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "nyx-sysproxy" +version = "1.0.0" +description = "Get/set the OS system proxy on Windows and Linux" +edition = "2024" +license = "MIT" + +[lib] +name = "nyx_sysproxy" + +[dependencies] +log = "0.4" +thiserror = "2" +url = "2" + +[target.'cfg(target_os = "linux")'.dependencies] +dirs = "6" + +[target.'cfg(windows)'.dependencies] +winreg = { version = "0.56", features = ["transactions"] } +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Networking_WinInet", + "Win32_NetworkManagement_Rras", + "Win32_System_Memory", +] } diff --git a/crates/nyx-sysproxy/src/lib.rs b/crates/nyx-sysproxy/src/lib.rs new file mode 100644 index 0000000..3f07755 --- /dev/null +++ b/crates/nyx-sysproxy/src/lib.rs @@ -0,0 +1,74 @@ +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "windows")] +mod windows; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Sysproxy { + pub host: String, + pub bypass: String, + pub port: u16, + pub enable: bool, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Autoproxy { + pub url: String, + pub enable: bool, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("failed to parse string `{0}`")] + ParseStr(String), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error("failed to set proxy for this environment")] + NotSupport, + + #[cfg(target_os = "windows")] + #[error("Windows system call failed: {0}")] + SystemCall(#[from] ::windows::core::Error), +} + +pub type Result = std::result::Result; + +impl Sysproxy { + pub const fn is_support() -> bool { + cfg!(any(target_os = "linux", target_os = "windows")) + } +} + +impl Autoproxy { + pub const fn is_support() -> bool { + cfg!(any(target_os = "linux", target_os = "windows")) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +impl Sysproxy { + pub fn get_system_proxy() -> Result { + Err(Error::NotSupport) + } + + pub fn set_system_proxy(&self) -> Result<()> { + Err(Error::NotSupport) + } + + pub fn set_system_proxy_with(&self, _include_ras: bool) -> Result<()> { + Err(Error::NotSupport) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +impl Autoproxy { + pub fn get_auto_proxy() -> Result { + Err(Error::NotSupport) + } + + pub fn set_auto_proxy(&self) -> Result<()> { + Err(Error::NotSupport) + } +} diff --git a/crates/nyx-sysproxy/src/linux.rs b/crates/nyx-sysproxy/src/linux.rs new file mode 100644 index 0000000..3732e0f --- /dev/null +++ b/crates/nyx-sysproxy/src/linux.rs @@ -0,0 +1,686 @@ +use crate::{Autoproxy, Error, Result, Sysproxy}; +use std::{env, process::Command, str::from_utf8, sync::LazyLock}; +use url::Url; + +const CMD_KEY: &str = "org.gnome.system.proxy"; + +static IS_APPIMAGE: LazyLock = LazyLock::new(|| std::env::var("APPIMAGE").is_ok()); + +impl Sysproxy { + #[inline] + pub fn get_system_proxy() -> Result { + let enable = Sysproxy::get_enable()?; + + let mut socks = get_proxy("socks")?; + let https = get_proxy("https")?; + let http = get_proxy("http")?; + + if socks.host.is_empty() { + if !http.host.is_empty() { + socks.host = http.host; + socks.port = http.port; + } + if !https.host.is_empty() { + socks.host = https.host; + socks.port = https.port; + } + } + + socks.enable = enable; + socks.bypass = Sysproxy::get_bypass().unwrap_or_else(|_| "".into()); + + Ok(socks) + } + + #[inline] + pub fn set_system_proxy(&self) -> Result<()> { + self.set_enable()?; + + if self.enable { + self.set_socks()?; + self.set_https()?; + self.set_http()?; + self.set_bypass()?; + } + + Ok(()) + } + + /// Mirrors the Windows signature; Linux has no per-connection (RAS) proxy. + #[inline] + pub fn set_system_proxy_with(&self, _include_ras: bool) -> Result<()> { + self.set_system_proxy() + } + + #[inline] + pub fn get_enable() -> Result { + match env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().as_str() { + "KDE" => { + let config_path = kioslaverc_path()?; + + let mode = kreadconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + "ProxyType", + ]) + .output()?; + let mode = from_utf8(&mode.stdout) + .map_err(|_| Error::ParseStr("mode".into()))? + .trim(); + Ok(mode == "1") + } + _ => { + let mode = gsettings().args(["get", CMD_KEY, "mode"]).output()?; + let mode = from_utf8(&mode.stdout) + .map_err(|_| Error::ParseStr("mode".into()))? + .trim(); + Ok(mode == "'manual'") + } + } + } + + #[inline] + pub fn get_bypass() -> Result { + match env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().as_str() { + "KDE" => { + let config_path = kioslaverc_path()?; + + let bypass = kreadconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + "NoProxyFor", + ]) + .output()?; + let bypass = from_utf8(&bypass.stdout) + .map_err(|_| Error::ParseStr("bypass".into()))? + .trim(); + + let bypass = bypass + .split(',') + .map(|h| strip_str(h.trim())) + .collect::>() + .join(","); + + Ok(bypass) + } + _ => { + let bypass = gsettings() + .args(["get", CMD_KEY, "ignore-hosts"]) + .output()?; + let bypass = from_utf8(&bypass.stdout) + .map_err(|_| Error::ParseStr("bypass".into()))? + .trim(); + + let bypass = bypass.strip_prefix('[').unwrap_or(bypass); + let bypass = bypass.strip_suffix(']').unwrap_or(bypass); + + let bypass = bypass + .split(',') + .map(|h| strip_str(h.trim())) + .collect::>() + .join(","); + + Ok(bypass) + } + } + } + + #[inline] + pub fn get_http() -> Result { + get_proxy("http") + } + + #[inline] + pub fn get_https() -> Result { + get_proxy("https") + } + + #[inline] + pub fn get_socks() -> Result { + get_proxy("socks") + } + + #[inline] + pub fn set_enable(&self) -> Result<()> { + match env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().as_str() { + "KDE" => { + let config_path = kioslaverc_path()?; + let mode = if self.enable { "1" } else { "0" }; + kwriteconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + "ProxyType", + mode, + ]) + .status()?; + let gmode = if self.enable { "'manual'" } else { "'none'" }; + gsettings().args(["set", CMD_KEY, "mode", gmode]).status()?; + write_dconf("/system/proxy/mode", gmode); + Ok(()) + } + _ => { + let mode = if self.enable { "'manual'" } else { "'none'" }; + gsettings().args(["set", CMD_KEY, "mode", mode]).status()?; + write_dconf("/system/proxy/mode", mode); + Ok(()) + } + } + } + + #[inline] + pub fn set_bypass(&self) -> Result<()> { + match env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().as_str() { + "KDE" => { + let config_path = kioslaverc_path()?; + + let bypass = self + .bypass + .split(',') + .map(|h| { + let mut host = String::from(h.trim()); + if !host.starts_with('\'') && !host.starts_with('"') { + host = String::from("'") + &host; + } + if !host.ends_with('\'') && !host.ends_with('"') { + host += "'"; + } + host + }) + .collect::>() + .join(", "); + + let bypass = format!("[{bypass}]"); + + gsettings() + .args(["set", CMD_KEY, "ignore-hosts", bypass.as_str()]) + .status()?; + write_dconf("/system/proxy/ignore-hosts", bypass.as_str()); + + kwriteconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + "NoProxyFor", + self.bypass.as_str(), + ]) + .status()?; + Ok(()) + } + _ => { + let bypass = self + .bypass + .split(',') + .map(|h| { + let mut host = String::from(h.trim()); + if !host.starts_with('\'') && !host.starts_with('"') { + host = String::from("'") + &host; + } + if !host.ends_with('\'') && !host.ends_with('"') { + host += "'"; + } + host + }) + .collect::>() + .join(", "); + + let bypass = format!("[{bypass}]"); + + gsettings() + .args(["set", CMD_KEY, "ignore-hosts", bypass.as_str()]) + .status()?; + write_dconf("/system/proxy/ignore-hosts", bypass.as_str()); + Ok(()) + } + } + } + + #[inline] + pub fn set_http(&self) -> Result<()> { + set_proxy(self, "http") + } + + #[inline] + pub fn set_https(&self) -> Result<()> { + set_proxy(self, "https") + } + + #[inline] + pub fn set_socks(&self) -> Result<()> { + set_proxy(self, "socks") + } +} + +#[inline] +fn gsettings() -> Command { + let mut command = Command::new("gsettings"); + if *IS_APPIMAGE { + command.env_remove("LD_LIBRARY_PATH"); + } + command +} + +#[inline] +fn dconf() -> Command { + let mut command = Command::new("dconf"); + if *IS_APPIMAGE { + command.env_remove("LD_LIBRARY_PATH"); + } + command +} + +#[inline] +fn write_dconf(path: &str, value: &str) { + let _ = dconf().arg("write").arg(path).arg(value).status(); +} + +#[inline] +fn kioslaverc_path() -> Result { + dirs::config_dir() + .map(|dir| dir.join("kioslaverc")) + .and_then(|path| path.to_str().map(|value| value.to_owned())) + .ok_or_else(|| Error::ParseStr("config".into())) +} + +#[inline] +fn quoted(value: &str) -> String { + if value.starts_with('\'') && value.ends_with('\'') { + value.to_string() + } else { + format!("'{}'", value) + } +} + +#[inline] +fn kreadconfig() -> Command { + let command = match env::var("KDE_SESSION_VERSION").unwrap_or_default().as_str() { + "6" => "kreadconfig6", + _ => "kreadconfig5", + }; + let mut command = Command::new(command); + if *IS_APPIMAGE { + command.env_remove("LD_LIBRARY_PATH"); + } + command +} + +#[inline] +fn kwriteconfig() -> Command { + let command = match env::var("KDE_SESSION_VERSION").unwrap_or_default().as_str() { + "6" => "kwriteconfig6", + _ => "kwriteconfig5", + }; + let mut command = Command::new(command); + if *IS_APPIMAGE { + command.env_remove("LD_LIBRARY_PATH"); + } + command +} + +#[inline] +fn format_kde_proxy_value(service: &str, host: &str, port: u16) -> String { + let host = if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) { + format!("[{host}]") + } else { + host.to_string() + }; + + format!("{service}://{host}:{port}") +} + +#[inline] +fn set_proxy(proxy: &Sysproxy, service: &str) -> Result<()> { + match env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().as_str() { + "KDE" => { + let schema = format!("{CMD_KEY}.{service}"); + let schema = schema.as_str(); + + let host = format!("'{}'", proxy.host); + let host = host.as_str(); + let port = format!("{}", proxy.port); + let port = port.as_str(); + let dconf_service = service; + + gsettings().args(["set", schema, "host", host]).status()?; + gsettings().args(["set", schema, "port", port]).status()?; + let host_path = format!("/system/proxy/{dconf_service}/host"); + let port_path = format!("/system/proxy/{dconf_service}/port"); + write_dconf(host_path.as_str(), host); + write_dconf(port_path.as_str(), port); + + let config_path = kioslaverc_path()?; + + let key = format!("{service}Proxy"); + let key = key.as_str(); + + let service = match service { + "socks" => "socks", + _ => "http", + }; + + let schema = format_kde_proxy_value(service, proxy.host.as_str(), proxy.port); + let schema = schema.as_str(); + + kwriteconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + key, + schema, + ]) + .status()?; + + Ok(()) + } + _ => { + let schema = format!("{CMD_KEY}.{service}"); + let schema = schema.as_str(); + + let host = format!("'{}'", proxy.host); + let host = host.as_str(); + let port = format!("{}", proxy.port); + let port = port.as_str(); + let dconf_service = service; + + gsettings().args(["set", schema, "host", host]).status()?; + gsettings().args(["set", schema, "port", port]).status()?; + let host_path = format!("/system/proxy/{dconf_service}/host"); + let port_path = format!("/system/proxy/{dconf_service}/port"); + write_dconf(host_path.as_str(), host); + write_dconf(port_path.as_str(), port); + + Ok(()) + } + } +} + +#[inline] +fn get_proxy(service: &str) -> Result { + match env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().as_str() { + "KDE" => { + let config_path = kioslaverc_path()?; + + let key = format!("{service}Proxy"); + let key = key.as_str(); + + let schema = kreadconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + key, + ]) + .output()?; + let schema = from_utf8(&schema.stdout) + .map_err(|_| Error::ParseStr("schema".into()))? + .trim(); + let schema = strip_str(schema); + let (host, port) = parse_kde_proxy(schema, service)?; + + Ok(Sysproxy { + enable: false, + host, + port, + bypass: "".into(), + }) + } + _ => { + let schema = format!("{CMD_KEY}.{service}"); + let schema = schema.as_str(); + + let host = gsettings().args(["get", schema, "host"]).output()?; + let host = from_utf8(&host.stdout) + .map_err(|_| Error::ParseStr("host".into()))? + .trim(); + let host = strip_str(host); + + let port = gsettings().args(["get", schema, "port"]).output()?; + let port = from_utf8(&port.stdout) + .map_err(|_| Error::ParseStr("port".into()))? + .trim(); + let port = port.parse().unwrap_or(80u16); + + Ok(Sysproxy { + enable: false, + host: String::from(host), + port, + bypass: "".into(), + }) + } + } +} + +#[inline] +fn strip_str(text: &str) -> &str { + text.strip_prefix('\'') + .unwrap_or(text) + .strip_suffix('\'') + .unwrap_or(text) +} + +#[inline] +fn parse_url(schema: &str) -> Option<(String, u16)> { + let url = Url::parse(schema.trim()).ok()?; + Some(( + url.host_str()?.to_string(), + url.port_or_known_default().unwrap_or(0u16), + )) +} + +#[inline] +fn parse_kde_proxy(schema: &str, service: &str) -> Result<(String, u16)> { + let schema = schema.trim(); + if schema.is_empty() { + // KDE's default kioslaverc may omit per-scheme entries; empty means unset. + return Ok(("".into(), 0)); + } + + let (scheme, default_port) = match service { + "socks" => ("socks", 1080), + "https" => ("https", 443), + _ => ("http", 80), + }; + + let parse = |candidate: &str| { + parse_url(candidate).map(|(host, port)| (host, if port == 0 { default_port } else { port })) + }; + + if let Some(result) = parse(schema) { + return Ok(result); + } + + // Legacy KDE format: " " + let mut whitespace = schema.split_whitespace(); + if let (Some(endpoint), Some(port)) = (whitespace.next(), whitespace.next()) { + let candidate = if endpoint.contains("://") { + format!("{endpoint}:{port}") + } else { + format!("{scheme}://{endpoint}:{port}") + }; + if let Some(result) = parse(candidate.as_str()) { + return Ok(result); + } + } + + if !schema.contains("://") { + let candidate = format!("{scheme}://{schema}"); + if let Some(result) = parse(candidate.as_str()) { + return Ok(result); + } + } + + Err(Error::ParseStr("schema".into())) +} + +impl Autoproxy { + #[inline] + pub fn get_auto_proxy() -> Result { + let (enable, url) = match env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().as_str() { + "KDE" => { + let config_path = kioslaverc_path()?; + + let mode = kreadconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + "ProxyType", + ]) + .output()?; + let mode = from_utf8(&mode.stdout) + .map_err(|_| Error::ParseStr("mode".into()))? + .trim(); + let url = kreadconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + "Proxy Config Script", + ]) + .output()?; + let url = from_utf8(&url.stdout) + .map_err(|_| Error::ParseStr("url".into()))? + .trim(); + (mode == "2", url.to_string()) + } + _ => { + let mode = gsettings().args(["get", CMD_KEY, "mode"]).output()?; + let mode = from_utf8(&mode.stdout) + .map_err(|_| Error::ParseStr("mode".into()))? + .trim(); + let url = gsettings() + .args(["get", CMD_KEY, "autoconfig-url"]) + .output()?; + let url: &str = from_utf8(&url.stdout) + .map_err(|_| Error::ParseStr("url".into()))? + .trim(); + let url = strip_str(url); + (mode == "'auto'", url.to_string()) + } + }; + + Ok(Autoproxy { enable, url }) + } + + #[inline] + pub fn set_auto_proxy(&self) -> Result<()> { + match env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().as_str() { + "KDE" => { + let config_path = kioslaverc_path()?; + let mode = if self.enable { "2" } else { "0" }; + kwriteconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + "ProxyType", + mode, + ]) + .status()?; + kwriteconfig() + .args([ + "--file", + config_path.as_str(), + "--group", + "Proxy Settings", + "--key", + "Proxy Config Script", + &self.url, + ]) + .status()?; + let gmode = if self.enable { "'auto'" } else { "'none'" }; + gsettings().args(["set", CMD_KEY, "mode", gmode]).status()?; + write_dconf("/system/proxy/mode", gmode); + let autoconfig = quoted(&self.url); + gsettings() + .args(["set", CMD_KEY, "autoconfig-url", autoconfig.as_str()]) + .status()?; + write_dconf("/system/proxy/autoconfig-url", autoconfig.as_str()); + } + _ => { + let mode = if self.enable { "'auto'" } else { "'none'" }; + gsettings().args(["set", CMD_KEY, "mode", mode]).status()?; + write_dconf("/system/proxy/mode", mode); + let autoconfig = quoted(&self.url); + gsettings() + .args(["set", CMD_KEY, "autoconfig-url", autoconfig.as_str()]) + .status()?; + write_dconf("/system/proxy/autoconfig-url", autoconfig.as_str()); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_legacy_spaced_http_entry() { + let (host, port) = parse_kde_proxy("http://127.0.0.1 7897", "http").unwrap(); + assert_eq!(host, "127.0.0.1"); + assert_eq!(port, 7897); + } + + #[test] + fn parse_legacy_spaced_socks_entry_without_scheme() { + let (host, port) = parse_kde_proxy("127.0.0.1 7897", "socks").unwrap(); + assert_eq!(host, "127.0.0.1"); + assert_eq!(port, 7897); + } + + #[test] + fn parse_plasma_colon_entry() { + let (host, port) = parse_kde_proxy("http://127.0.0.1:7897", "http").unwrap(); + assert_eq!(host, "127.0.0.1"); + assert_eq!(port, 7897); + } + + #[test] + fn parse_url_without_port_defaults_to_80() { + let (host, port) = parse_kde_proxy("http://127.0.0.1", "http").unwrap(); + assert_eq!(host, "127.0.0.1"); + assert_eq!(port, 80); + } + + #[test] + fn parse_https_without_port_defaults_to_443() { + let (host, port) = parse_kde_proxy("https://proxy.example.com", "https").unwrap(); + assert_eq!(host, "proxy.example.com"); + assert_eq!(port, 443); + } + + #[test] + fn empty_schema_returns_empty_result() { + let (host, port) = parse_kde_proxy("", "http").unwrap(); + assert_eq!(host, ""); + assert_eq!(port, 0); + } +} diff --git a/crates/nyx-sysproxy/src/windows.rs b/crates/nyx-sysproxy/src/windows.rs new file mode 100644 index 0000000..ed7ed0f --- /dev/null +++ b/crates/nyx-sysproxy/src/windows.rs @@ -0,0 +1,394 @@ +use crate::{Autoproxy, Result, Sysproxy}; +use ::windows::{ + Win32::{ + NetworkManagement::Rras::{ERROR_BUFFER_TOO_SMALL, RASENTRYNAMEW, RasEnumEntriesW}, + Networking::WinInet::{ + INTERNET_OPTION_PER_CONNECTION_OPTION, INTERNET_OPTION_PROXY_SETTINGS_CHANGED, + INTERNET_OPTION_REFRESH, INTERNET_PER_CONN_AUTOCONFIG_URL, INTERNET_PER_CONN_FLAGS, + INTERNET_PER_CONN_OPTION_LISTW, INTERNET_PER_CONN_OPTIONW, INTERNET_PER_CONN_OPTIONW_0, + INTERNET_PER_CONN_PROXY_BYPASS, INTERNET_PER_CONN_PROXY_SERVER, InternetSetOptionW, + PROXY_TYPE_AUTO_DETECT, PROXY_TYPE_AUTO_PROXY_URL, PROXY_TYPE_DIRECT, PROXY_TYPE_PROXY, + }, + System::Memory::{GetProcessHeap, HEAP_NONE, HEAP_ZERO_MEMORY, HeapAlloc, HeapFree}, + }, + core::{PCWSTR, PWSTR}, +}; +use std::{ffi::c_void, mem::size_of}; +use url::Url; +use winreg::{RegKey, enums}; + +const SUB_KEY: &str = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"; + +fn encode_wide>(string: S) -> Vec { + std::os::windows::prelude::OsStrExt::encode_wide(string.as_ref()) + .chain(std::iter::once(0)) + .collect::>() +} + +/// A dial-up/VPN connection whose name contains non-ASCII characters may not +/// take the proxy; renaming it in ASCII is the workaround. +fn unset_proxy(include_ras: bool) -> Result<()> { + let mut p_opts = Vec::::with_capacity(1); + p_opts.push(INTERNET_PER_CONN_OPTIONW { + dwOption: INTERNET_PER_CONN_FLAGS, + Value: { + let mut v = INTERNET_PER_CONN_OPTIONW_0::default(); + v.dwValue = PROXY_TYPE_DIRECT; + v + }, + }); + let mut opts = INTERNET_PER_CONN_OPTION_LISTW { + dwSize: size_of::() as u32, + dwOptionCount: 1, + dwOptionError: 0, + pOptions: p_opts.as_mut_ptr(), + pszConnection: PWSTR::null(), + }; + + apply_option(&opts)?; + if include_ras { + for ras_conn in get_ras_connections()?.iter() { + let conn_wide = encode_wide(ras_conn); + opts.pszConnection = PWSTR::from_raw(conn_wide.as_ptr() as *mut u16); + apply_option(&opts)?; + log::debug!("unset RAS[{ras_conn}] proxy success"); + } + } + notify_proxy_change() +} + +#[inline] +fn set_auto_proxy(url: &str) -> Result<()> { + let s = encode_wide(url); + let mut p_opts = Vec::::with_capacity(2); + p_opts.push(INTERNET_PER_CONN_OPTIONW { + dwOption: INTERNET_PER_CONN_FLAGS, + Value: INTERNET_PER_CONN_OPTIONW_0 { + dwValue: PROXY_TYPE_AUTO_DETECT | PROXY_TYPE_AUTO_PROXY_URL | PROXY_TYPE_DIRECT, + }, + }); + p_opts.push(INTERNET_PER_CONN_OPTIONW { + dwOption: INTERNET_PER_CONN_AUTOCONFIG_URL, + Value: INTERNET_PER_CONN_OPTIONW_0 { + pszValue: PWSTR::from_raw(s.as_ptr() as *mut u16), + }, + }); + + let mut opts = INTERNET_PER_CONN_OPTION_LISTW { + dwSize: size_of::() as u32, + dwOptionCount: 2, + dwOptionError: 0, + pOptions: p_opts.as_mut_ptr(), + pszConnection: PWSTR::null(), + }; + + apply_option(&opts)?; + let ras_conns = get_ras_connections()?; + for ras_conn in ras_conns.iter() { + let conn_wide = encode_wide(ras_conn); + opts.pszConnection = PWSTR::from_raw(conn_wide.as_ptr() as *mut u16); + apply_option(&opts)?; + log::debug!("set RAS[{ras_conn}] auto proxy success"); + } + notify_proxy_change() +} + +#[inline] +fn set_global_proxy(server: &str, bypass: &str, include_ras: bool) -> Result<()> { + let s = encode_wide(server); + let b = encode_wide(bypass); + let mut p_opts = Vec::::with_capacity(3); + p_opts.push(INTERNET_PER_CONN_OPTIONW { + dwOption: INTERNET_PER_CONN_FLAGS, + Value: INTERNET_PER_CONN_OPTIONW_0 { + dwValue: PROXY_TYPE_PROXY | PROXY_TYPE_DIRECT, + }, + }); + p_opts.push(INTERNET_PER_CONN_OPTIONW { + dwOption: INTERNET_PER_CONN_PROXY_SERVER, + Value: INTERNET_PER_CONN_OPTIONW_0 { + pszValue: PWSTR::from_raw(s.as_ptr() as *mut u16), + }, + }); + p_opts.push(INTERNET_PER_CONN_OPTIONW { + dwOption: INTERNET_PER_CONN_PROXY_BYPASS, + Value: INTERNET_PER_CONN_OPTIONW_0 { + pszValue: PWSTR::from_raw(b.as_ptr() as *mut u16), + }, + }); + + let mut opts = INTERNET_PER_CONN_OPTION_LISTW { + dwSize: size_of::() as u32, + dwOptionCount: 3, + dwOptionError: 0, + pOptions: p_opts.as_mut_ptr(), + pszConnection: PWSTR::null(), + }; + apply_option(&opts)?; + if include_ras { + for ras_conn in get_ras_connections()?.iter() { + let conn_wide = encode_wide(ras_conn); + opts.pszConnection = PWSTR::from_raw(conn_wide.as_ptr() as *mut u16); + apply_option(&opts)?; + log::debug!("set RAS[{ras_conn}] global proxy success"); + } + } + notify_proxy_change() +} + +#[inline] +fn apply_option(options: &INTERNET_PER_CONN_OPTION_LISTW) -> Result<()> { + unsafe { + let opts = options as *const INTERNET_PER_CONN_OPTION_LISTW as *const c_void; + InternetSetOptionW( + None, + INTERNET_OPTION_PER_CONNECTION_OPTION, + Some(opts), + size_of::() as u32, + )?; + } + Ok(()) +} + +#[inline] +fn notify_proxy_change() -> Result<()> { + unsafe { + InternetSetOptionW(None, INTERNET_OPTION_PROXY_SETTINGS_CHANGED, None, 0)?; + InternetSetOptionW(None, INTERNET_OPTION_REFRESH, None, 0)?; + } + Ok(()) +} + +impl Sysproxy { + #[inline] + pub fn get_system_proxy() -> Result { + let hkcu = RegKey::predef(enums::HKEY_CURRENT_USER); + let cur_var = hkcu.open_subkey_with_flags(SUB_KEY, enums::KEY_QUERY_VALUE)?; + let enable = cur_var.get_value::("ProxyEnable").unwrap_or(0u32) == 1u32; + let proxy_server = cur_var + .get_value::("ProxyServer") + .unwrap_or_default(); + + let mut host = String::new(); + let mut port = 0u16; + + if !proxy_server.is_empty() { + if proxy_server.contains('=') { + // Multi-protocol form: http=127.0.0.1:7890;https=127.0.0.1:7890 — http wins. + let http_proxy = proxy_server + .split(';') + .find(|part| { + let t = part.trim().as_bytes(); + t.len() >= 5 && t[..5].eq_ignore_ascii_case(b"http=") + }) + .or_else(|| proxy_server.split(';').next()); + + if let Some(proxy) = http_proxy { + let proxy_value = proxy.split('=').nth(1).unwrap_or(""); + parse_proxy_address(proxy_value, &mut host, &mut port); + } + } else { + parse_proxy_address(&proxy_server, &mut host, &mut port); + } + } + + let bypass = cur_var.get_value("ProxyOverride").unwrap_or_default(); + + Ok(Sysproxy { + enable, + host, + port, + bypass, + }) + } + + #[inline] + pub fn set_system_proxy(&self) -> Result<()> { + self.set_system_proxy_with(true) + } + + #[inline] + pub fn set_system_proxy_with(&self, include_ras: bool) -> Result<()> { + match self.enable { + true => set_global_proxy( + &format!("{}:{}", self.host, self.port), + &self.bypass, + include_ras, + ), + false => unset_proxy(include_ras), + } + } +} + +impl Autoproxy { + #[inline] + pub fn get_auto_proxy() -> Result { + let hkcu = RegKey::predef(enums::HKEY_CURRENT_USER); + let cur_var = hkcu.open_subkey_with_flags(SUB_KEY, enums::KEY_QUERY_VALUE)?; + let url = cur_var.get_value::("AutoConfigURL"); + let enable = url.is_ok(); + let url = url.unwrap_or_default(); + + Ok(Autoproxy { enable, url }) + } + + #[inline] + pub fn set_auto_proxy(&self) -> Result<()> { + match self.enable { + true => set_auto_proxy(&self.url), + false => unset_proxy(true), + } + } +} + +#[inline] +fn parse_proxy_address(address: &str, host: &mut String, port: &mut u16) { + if let Some((h, p)) = address.rsplit_once(':') + && let Ok(port_num) = p.parse::() + { + let clean = if h.starts_with('[') && h.ends_with(']') { + &h[1..h.len() - 1] + } else { + h + }; + *host = clean.to_string(); + *port = port_num; + return; + } + + if let Ok(url) = Url::parse(&format!("http://{}", address)) { + *host = url.host_str().unwrap_or("").to_string(); + *port = url.port().unwrap_or(80); + return; + } + + *host = address.to_string(); + *port = 80; +} + +/// Every RAS entry, i.e. every dial-up and VPN connection. +fn get_ras_connections() -> Result> { + log::debug!("start get RAS connections..."); + let mut buffer_size = 0u32; + let mut entry_count = 0u32; + + let result_code = unsafe { + RasEnumEntriesW( + PCWSTR::null(), + PCWSTR::null(), + None, + &mut buffer_size, + &mut entry_count, + ) + }; + log::debug!("get allocate buffer size result code: {result_code}"); + + if result_code == ERROR_BUFFER_TOO_SMALL { + return unsafe { enumerate_ras_entries(buffer_size) }; + } + + if entry_count >= 1 { + log::error!("The operation failed to acquire the buffer size"); + } else { + log::debug!("There were no RAS entry names found"); + } + Ok(Vec::new()) +} + +unsafe fn enumerate_ras_entries(buffer_size: u32) -> Result> { + let heap = unsafe { GetProcessHeap()? }; + + let (buffer_ptr, lp_ras_entry_name) = unsafe { + let ptr = HeapAlloc(heap, HEAP_ZERO_MEMORY, buffer_size as usize); + if ptr.is_null() { + log::error!("HeapAlloc failed!"); + return Ok(Vec::new()); + } + let lp = ptr as *mut RASENTRYNAMEW; + (*lp).dwSize = size_of::() as u32; + (ptr, lp) + }; + + let mut actual_size = buffer_size; + let mut entry_count = 0u32; + let result_code = unsafe { + RasEnumEntriesW( + PCWSTR::null(), + PCWSTR::null(), + Some(lp_ras_entry_name), + &mut actual_size, + &mut entry_count, + ) + }; + log::debug!("get RAS entries result code: {result_code}"); + + let mut connections = Vec::with_capacity(entry_count as usize); + if result_code == 0 && entry_count > 0 { + for i in 0..entry_count as isize { + let entry = unsafe { &*lp_ras_entry_name.offset(i) }; + let name_arr = entry.szEntryName; + let len = name_arr.iter().position(|&x| x == 0).unwrap_or(0); + connections.push(String::from_utf16_lossy(&name_arr[..len])); + } + log::debug!( + "Found {} dial-up connection/VPN, {:?}", + connections.len(), + connections + ); + } + + unsafe { HeapFree(heap, HEAP_NONE, Some(buffer_ptr))? }; + Ok(connections) +} + +#[cfg(test)] +mod tests { + use super::parse_proxy_address; + + fn parse(addr: &str) -> (String, u16) { + let mut host = String::new(); + let mut port = 0u16; + parse_proxy_address(addr, &mut host, &mut port); + (host, port) + } + + #[test] + fn test_ipv4_with_port() { + assert_eq!(parse("127.0.0.1:8080"), ("127.0.0.1".into(), 8080)); + } + + #[test] + fn test_hostname_with_port() { + assert_eq!( + parse("proxy.example.com:3128"), + ("proxy.example.com".into(), 3128) + ); + } + + #[test] + fn test_ipv6_bracketed_with_port() { + assert_eq!(parse("[::1]:1080"), ("::1".into(), 1080)); + } + + #[test] + fn test_hostname_only_defaults_port_80() { + assert_eq!(parse("proxy.example.com"), ("proxy.example.com".into(), 80)); + } + + #[test] + fn test_ipv4_only_defaults_port_80() { + assert_eq!(parse("192.168.1.1"), ("192.168.1.1".into(), 80)); + } + + #[test] + fn test_empty_string() { + let (host, port) = parse(""); + assert_eq!(port, 80); + assert!(host.is_empty()); + } + + #[test] + fn test_high_port() { + assert_eq!(parse("10.0.0.1:65535"), ("10.0.0.1".into(), 65535)); + } +} diff --git a/flake.lock b/flake.lock index 79f505a..6f01576 100644 --- a/flake.lock +++ b/flake.lock @@ -1,23 +1,5 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixpkgs": { "locked": { "lastModified": 1782723713, @@ -36,9 +18,9 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" + "rust-overlay": "rust-overlay", + "systems": "systems" } }, "rust-overlay": { @@ -63,16 +45,16 @@ }, "systems": { "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "lastModified": 1689347949, + "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=", "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "repo": "default-linux", + "rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68", "type": "github" }, "original": { "owner": "nix-systems", - "repo": "default", + "repo": "default-linux", "type": "github" } } diff --git a/flake.nix b/flake.nix index ad64754..dbe44be 100644 --- a/flake.nix +++ b/flake.nix @@ -3,7 +3,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; + systems.url = "github:nix-systems/default-linux"; rust-overlay = { url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; @@ -11,223 +11,136 @@ }; outputs = - { self - , nixpkgs - , flake-utils - , rust-overlay - , + { + self, + nixpkgs, + systems, + rust-overlay, }: let - supportedSystems = [ - "x86_64-linux" - "aarch64-linux" - ]; + inherit (nixpkgs) lib; + eachSystem = f: lib.foldl' lib.recursiveUpdate { } (map f (import systems)); in - flake-utils.lib.eachSystem supportedSystems - ( - system: - let - pkgs = import nixpkgs { - inherit system; - overlays = [ (import rust-overlay) ]; - }; - - rustToolchain = pkgs.rust-bin.stable.latest.default.override { - extensions = [ - "rust-src" - "rust-analyzer" - "clippy" - "rustfmt" - ]; - }; - rustPlatform = pkgs.makeRustPlatform { - cargo = rustToolchain; - rustc = rustToolchain; - }; - - runtimeLibs = with pkgs; [ - wayland - libxkbcommon - libx11 - libxcb - libxcursor - libxi - libxrandr - vulkan-loader - libGL - fontconfig - freetype - gtk3 - glib - xdotool - openssl - ]; + eachSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; - nativeBuildInputs = with pkgs; [ - pkg-config - rustPlatform.bindgenHook # gpui builds bindgen-based crates - autoPatchelfHook - makeWrapper - wrapGAppsHook3 + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ + "rust-src" + "rust-analyzer" + "clippy" + "rustfmt" ]; + }; + rustPlatform = pkgs.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }; - nyx = rustPlatform.buildRustPackage { - pname = "nyx"; - version = "2.0.6"; - - src = pkgs.lib.cleanSource ./.; - - cargoLock = { - lockFile = ./Cargo.lock; - allowBuiltinFetchGit = true; - }; + runtimeLibs = with pkgs; [ + wayland + libxkbcommon + libx11 + libxcb + libxcursor + libxi + libxrandr + vulkan-loader + libGL + fontconfig + freetype + gtk3 + glib + xdotool + openssl + ]; + + nativeBuildInputs = with pkgs; [ + pkg-config + rustPlatform.bindgenHook # gpui builds bindgen-based crates + autoPatchelfHook + makeWrapper + wrapGAppsHook3 + ]; + + nyx = rustPlatform.buildRustPackage { + pname = "nyx"; + version = "2.1.0"; + + src = pkgs.lib.cleanSource ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + allowBuiltinFetchGit = true; + }; - inherit nativeBuildInputs; - buildInputs = runtimeLibs; + inherit nativeBuildInputs; + buildInputs = runtimeLibs; - # gpui dlopens Vulkan/Wayland/GL at runtime; bake them into the rpath. - runtimeDependencies = runtimeLibs; + # gpui dlopens Vulkan/Wayland/GL at runtime; bake them into the rpath. + runtimeDependencies = runtimeLibs; - # Heavy GPU/UI crate graph: skip the (nonexistent) test suite. - doCheck = false; + # Heavy GPU/UI crate graph: skip the (nonexistent) test suite. + doCheck = false; - postInstall = '' - install -Dm644 installer/linux/nyx.desktop \ - $out/share/applications/nyx.desktop - install -Dm644 assets/brand/logo.png \ - $out/share/icons/hicolor/512x512/apps/nyx.png - ''; + postInstall = '' + install -Dm644 installer/linux/nyx.desktop \ + $out/share/applications/nyx.desktop + install -Dm644 assets/brand/logo.png \ + $out/share/icons/hicolor/512x512/apps/nyx.png + ''; - meta = with pkgs.lib; { - description = "Mihomo/Clash GUI (pure-Rust gpui app)"; - homepage = "https://github.com/BX-Team/Nyx"; - license = licenses.gpl3Plus; - platforms = supportedSystems; - mainProgram = "nyx"; - }; - }; - in - { - packages = { - default = nyx; - inherit nyx; + meta = with pkgs.lib; { + description = "Mihomo/Clash GUI"; + homepage = "https://github.com/BX-Team/Nyx"; + license = licenses.gpl3Plus; + platforms = import systems; + mainProgram = "nyx"; }; + }; + in + { + packages.${system} = { + default = nyx; + inherit nyx; + }; - apps.default = { - type = "app"; - program = "${nyx}/bin/nyx"; - }; + apps.${system}.default = { + type = "app"; + program = "${nyx}/bin/nyx"; + }; - devShells.default = pkgs.mkShell { - buildInputs = runtimeLibs; - nativeBuildInputs = - nativeBuildInputs - ++ (with pkgs; [ - rustToolchain - git - cargo-deb - ]); - - shellHook = '' - export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}:$LD_LIBRARY_PATH" - export PKG_CONFIG_PATH="${ - pkgs.lib.makeSearchPathOutput "dev" "lib/pkgconfig" runtimeLibs - }:$PKG_CONFIG_PATH" - echo "Nyx dev shell ready." - echo " cargo run # run the app" - echo " cargo build --release # optimized binary" - ''; - }; + devShells.${system}.default = pkgs.mkShell { + buildInputs = runtimeLibs; + nativeBuildInputs = + nativeBuildInputs + ++ (with pkgs; [ + rustToolchain + git + cargo-deb + ]); + + shellHook = '' + export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}:$LD_LIBRARY_PATH" + export PKG_CONFIG_PATH="${ + pkgs.lib.makeSearchPathOutput "dev" "lib/pkgconfig" runtimeLibs + }:$PKG_CONFIG_PATH" + echo "Nyx dev shell ready." + echo " cargo run # run the app" + echo " cargo build --release # optimized binary" + ''; + }; - formatter = pkgs.nixfmt-rfc-style; - } - ) + formatter.${system} = pkgs.nixfmt-rfc-style; + } + ) // { - # NixOS module: `imports = [ inputs.nyx.nixosModules.default ];` - nixosModules.default = - { config - , lib - , pkgs - , ... - }: - let - cfg = config.programs.nyx; - in - { - options.programs.nyx = { - enable = lib.mkEnableOption "Nyx Mihomo/Clash GUI"; - package = lib.mkOption { - type = lib.types.package; - default = self.packages.${pkgs.stdenv.hostPlatform.system}.default; - description = "The Nyx package to use."; - }; - tunMode = lib.mkEnableOption '' - TUN mode. Wraps the Nyx binary with cap_net_admin/cap_net_raw/ - cap_net_bind_service so the mihomo core it spawns can create a TUN - device without running as root''; - profiles = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "https://example.com/subscription" ]; - description = '' - Subscription URLs imported automatically on launch, so profiles - don't have to be added by hand. Idempotent: already-added URLs - are skipped and a failed fetch is retried next launch. Profile - names come from the subscription headers. Exported as the - NYX_PROFILES environment variable.''; - }; - profilesFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "/run/secrets/nyx-profiles"; - description = '' - Path to a file with subscription URLs (whitespace/newline - separated), imported like `profiles`. Use this for secret URLs - rendered by sops/agenix so they never land in the Nix store. - Exported as NYX_PROFILES_FILE.''; - }; - }; - - config = - let - needsWrap = cfg.profiles != [ ] || cfg.profilesFile != null; - # Bake the declared profile env vars into the binary. sessionVariables - # aren't reliably inherited by GUI-launched apps, so wrap instead — - # this reaches Nyx no matter how the desktop starts it. - wrapped = pkgs.symlinkJoin { - name = "nyx-with-profiles"; - paths = [ cfg.package ]; - nativeBuildInputs = [ pkgs.makeWrapper ]; - postBuild = '' - wrapProgram $out/bin/nyx \ - ${ - lib.optionalString ( - cfg.profiles != [ ] - ) "--set NYX_PROFILES ${lib.escapeShellArg (lib.concatStringsSep " " cfg.profiles)}" - } \ - ${lib.optionalString ( - cfg.profilesFile != null - ) "--set NYX_PROFILES_FILE ${lib.escapeShellArg cfg.profilesFile}"} - ''; - }; - runPackage = if needsWrap then wrapped else cfg.package; - in - lib.mkIf cfg.enable { - environment.systemPackages = [ runPackage ]; - programs.dconf.enable = lib.mkDefault true; - services.gnome.gnome-keyring.enable = lib.mkDefault true; - - # Caps live on the security wrapper; it raises them into the ambient - # set and execs runPackage, so the core Nyx spawns inherits them. - security.wrappers = lib.mkIf cfg.tunMode { - nyx = { - owner = "root"; - group = "root"; - capabilities = "cap_net_bind_service,cap_net_raw,cap_net_admin=+ep"; - source = "${runPackage}/bin/nyx"; - }; - }; - }; - }; + nixosModules.nyx = import ./nix/module.nix { inherit self; }; + nixosModules.default = self.nixosModules.nyx; }; } diff --git a/locales/en-US.yml b/locales/en-US.yml index a43a490..34d26cd 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -57,6 +57,8 @@ pages: emptyTitle: No profile yet emptyBody: Add a subscription or local config file to start using Nyx. emptyCta: Add profile + modeTun: TUN + modeSysProxy: System proxy proxies: testGroup: Test refresh: Refresh @@ -91,6 +93,9 @@ pages: activate: Activate count: "%{n} subscriptions" updateAll: Update all + menuSubscription: Subscription settings + menuEditConfig: Edit config + menuDelete: Delete add: Add importFailed: Import failed addTitle: Add subscription @@ -186,16 +191,6 @@ pages: corePrerelease: Prerelease coreUpdate: Update core svcSection: System service - tunSection: TUN access - tunStatus: TUN privileges - tunGranted: Granted - tunNotGranted: Not granted - tunGrant: Grant… - tunHint: "TUN mode needs network privileges. Click Grant to apply them via pkexec, or run manually: sudo setcap cap_net_admin,cap_net_bind_service,cap_net_raw=+ep $(which nyx). Restart Nyx to apply." - tunHintMac: TUN on macOS requires launching Nyx as root. - tunHintNixos: "On NixOS grant TUN declaratively: set programs.nyx.tunMode = true, rebuild, then relaunch Nyx — the nyx command resolves to the capability wrapper. A runtime setcap doesn't persist here." - tunGrantedToast: TUN privileges granted — restart Nyx to apply. - tunGrantFailed: Failed to grant TUN privileges svcStatus: Status svcRunning: Running svcStopped: Stopped @@ -207,6 +202,10 @@ pages: svcStart: Start svcStop: Stop svcRestart: Restart + svcStale: Needs repair + svcRepair: Repair… + svcHint: Nyx runs the mihomo core through a system service, which is what lets TUN mode create a network device. Installing, starting, stopping or removing it requires administrator rights. + svcManagedHint: The service comes from your system configuration, so Nyx neither installs nor removes it. Manage it there instead. sniffer: Sniffer snifferOverrideDest: Override destination snifferForceDns: Force DNS mapping @@ -260,9 +259,11 @@ pages: spBypass: Bypass alwaysOnTop: Always on top disableTray: Disable tray icon + systemWindowFrame: System window frame trayNodeInfo: Show node info in tray takeOverDns: Take over DNS takeOverSniffer: Take over sniffer + connectivityHint: TUN and the system proxy cannot run at the same time; turning one on turns the other off. overrideSettings: Override subscription overrideHint: When off, the active subscription's values are used (or defaults if it provides none). Turn on to force these settings. networkDetection: Network change detection @@ -276,6 +277,8 @@ pages: scQuitKeepCore: Quit (keep core) scHint: Click a shortcut, then press the keys. Esc cancels, Backspace clears. scPress: Press keys… + scWaylandTitle: Global shortcuts are not available on Wayland. + scWaylandHint: 'Wayland has no protocol for global key grabs — the compositor owns them. Bind these links in your compositor config instead, launching Nyx with the link as its argument. niri example: Mod+Shift+T { spawn "nyx" "nyx://toggle-tun"; }' tooltips: refresh: Refresh toggleStats: Toggle statistics @@ -284,9 +287,6 @@ tooltips: testLatency: Test latency activate: Activate update: Update - edit: Edit - editInfo: Edit info - delete: Delete configure: Configure unfix: Reset to auto restartConnections: Restart connections @@ -308,11 +308,8 @@ onboarding: profileBody: Import a subscription URL or a local config file — this is your proxy source. profileHint: Use the “Add” button at the top right. serviceTitle: Install the service - serviceBody: The background service lets Nyx manage the core and TUN mode without admin prompts. - serviceHint: Open Mihomo settings and install the service. - tunTitle: Choose how to route traffic - tunBody: "Two ways to route traffic. System proxy needs no privileges but only covers apps that honor the desktop/proxy settings. TUN/VPN mode covers the whole device but needs network privileges for the core." - tunHint: "On GNOME or Windows the system proxy is usually enough; on other Linux desktops use TUN for full coverage." + serviceBody: "Nyx runs the mihomo core through a system service — that is what gives TUN mode the network privileges it needs. Installing it asks for your password once." + serviceHint: "Open Settings → Mihomo → System service and install it." proxyTitle: Turn on the proxy proxyBody: Flip the main switch on the Home screen to route your traffic. proxyHint: Use the main toggle on Home. @@ -328,5 +325,11 @@ tray: connect: Connect disconnect: Disconnect restartCore: Restart core - quitNoCore: Quit without core quit: Quit +core: + failure: + coreMissing: The mihomo core is unavailable + configInvalid: The profile was rejected by the core + serviceUnavailable: The Nyx service is unavailable + timeout: The core did not start in time + other: The core could not be started diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index ad69a4b..4a5be98 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -57,6 +57,8 @@ pages: emptyTitle: Профиль ещё не добавлен emptyBody: Добавьте подписку или локальный файл конфигурации, чтобы начать работу с Nyx. emptyCta: Добавить профиль + modeTun: TUN + modeSysProxy: Системный прокси proxies: testGroup: Тест refresh: Обновить @@ -91,6 +93,9 @@ pages: activate: Активировать count: "Подписок: %{n}" updateAll: Обновить все + menuSubscription: Настройки подписки + menuEditConfig: Редактировать конфиг + menuDelete: Удалить add: Добавить importFailed: Не удалось импортировать addTitle: Добавить подписку @@ -186,16 +191,6 @@ pages: corePrerelease: Предварительная coreUpdate: Обновить ядро svcSection: Системная служба - tunSection: Доступ TUN - tunStatus: Права TUN - tunGranted: Выданы - tunNotGranted: Не выданы - tunGrant: Выдать… - tunHint: "Для режима TUN нужны сетевые привилегии. Нажмите «Выдать», чтобы применить их через pkexec, или вручную: sudo setcap cap_net_admin,cap_net_bind_service,cap_net_raw=+ep $(which nyx). Перезапустите Nyx, чтобы применить." - tunHintMac: TUN на macOS требует запуска Nyx от root. - tunHintNixos: "На NixOS выдайте доступ к TUN декларативно: programs.nyx.tunMode = true, пересоберите систему и перезапустите Nyx — команда nyx резолвится в обёртку с capability. Здесь setcap в рантайме не сохраняется." - tunGrantedToast: Права TUN выданы — перезапустите Nyx, чтобы применить. - tunGrantFailed: Не удалось выдать права TUN svcStatus: Статус svcRunning: Запущена svcStopped: Остановлена @@ -207,6 +202,10 @@ pages: svcStart: Запустить svcStop: Остановить svcRestart: Перезапустить + svcStale: Требует переустановки + svcRepair: Переустановить… + svcHint: Nyx запускает ядро mihomo через системную службу, и именно она позволяет режиму TUN создать сетевое устройство. Для установки, запуска, остановки или удаления потребуются права администратора. + svcManagedHint: Служба поставляется вашей системной конфигурацией, поэтому Nyx её не устанавливает и не удаляет. Управляйте ею там же. sniffer: Сниффер snifferOverrideDest: Переопределять назначение snifferForceDns: Принудительный DNS-маппинг @@ -260,9 +259,11 @@ pages: spBypass: Исключения alwaysOnTop: Поверх всех окон disableTray: Отключить значок в трее + systemWindowFrame: Системная рамка окна trayNodeInfo: Показывать узел в трее takeOverDns: Перехват DNS takeOverSniffer: Перехват сниффера + connectivityHint: TUN и системный прокси не работают одновременно. Включение одного выключает другой. overrideSettings: Переопределить подписку overrideHint: Когда выключено, используются значения активной подписки (или значения по умолчанию, если их нет). Включите, чтобы принудительно применить эти настройки. networkDetection: Определение смены сети @@ -275,6 +276,8 @@ pages: scRestart: Перезапустить приложение scQuitKeepCore: Выход (оставить ядро) scHint: Нажмите на шорткат, затем нужные клавиши. Esc — отмена, Backspace — очистить. + scWaylandTitle: Глобальные горячие клавиши недоступны в Wayland. + scWaylandHint: 'В Wayland нет протокола для глобального перехвата клавиш — ими управляет композитор. Пропишите эти ссылки в конфиге композитора, запуская Nyx со ссылкой в аргументах. Пример для niri: Mod+Shift+T { spawn "nyx" "nyx://toggle-tun"; }' scPress: Нажмите клавиши… tooltips: refresh: Обновить @@ -284,9 +287,6 @@ tooltips: testLatency: Проверить задержку activate: Активировать update: Обновить - edit: Редактировать - editInfo: Изменить данные - delete: Удалить configure: Настроить unfix: Сбросить в авто restartConnections: Перезапустить соединения @@ -308,11 +308,8 @@ onboarding: profileBody: Импортируйте ссылку на подписку или локальный файл конфигурации — это источник ваших прокси. profileHint: Нажмите кнопку «Добавить» справа вверху. serviceTitle: Установите сервис - serviceBody: Фоновый сервис позволяет Nyx управлять ядром и режимом TUN без запросов прав администратора. - serviceHint: Откройте настройки Mihomo и установите сервис. - tunTitle: Выберите способ маршрутизации - tunBody: "Два способа направить трафик. Системный прокси не требует привилегий, но охватывает только приложения, читающие настройки прокси рабочего стола. Режим TUN/VPN покрывает всё устройство, но требует сетевых привилегий для ядра." - tunHint: "На GNOME или Windows обычно достаточно системного прокси; на других окружениях Linux используйте TUN для полного охвата." + serviceBody: "Nyx запускает ядро mihomo через системную службу — именно она даёт режиму TUN нужные сетевые права. Установка один раз попросит пароль." + serviceHint: "Откройте Настройки → Mihomo → Системная служба и установите её." proxyTitle: Включите прокси proxyBody: Переключите главный тумблер на главном экране, чтобы направить трафик. proxyHint: Используйте главный переключатель на «Главной». @@ -328,5 +325,11 @@ tray: connect: Подключить disconnect: Отключить restartCore: Перезапустить ядро - quitNoCore: Выйти, оставив ядро quit: Выход +core: + failure: + coreMissing: Ядро mihomo недоступно + configInvalid: Ядро отклонило профиль + serviceUnavailable: Служба Nyx недоступна + timeout: Ядро не успело запуститься + other: Не удалось запустить ядро diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 5501e4f..8d9cd35 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -57,6 +57,8 @@ pages: emptyTitle: 尚未添加配置 emptyBody: 添加订阅或本地配置文件即可开始使用 Nyx。 emptyCta: 添加配置 + modeTun: TUN + modeSysProxy: 系统代理 proxies: testGroup: 测试 refresh: 刷新 @@ -91,6 +93,9 @@ pages: activate: 启用 count: "%{n} 个订阅" updateAll: 全部更新 + menuSubscription: 订阅设置 + menuEditConfig: 编辑配置 + menuDelete: 删除 add: 添加 importFailed: 导入失败 addTitle: 添加订阅 @@ -186,16 +191,6 @@ pages: corePrerelease: 预发布版 coreUpdate: 更新内核 svcSection: 系统服务 - tunSection: TUN 权限 - tunStatus: TUN 权限 - tunGranted: 已授予 - tunNotGranted: 未授予 - tunGrant: 授予… - tunHint: "TUN 模式需要网络权限。点击“授予”通过 pkexec 应用,或手动运行:sudo setcap cap_net_admin,cap_net_bind_service,cap_net_raw=+ep $(which nyx)。重启 Nyx 生效。" - tunHintMac: macOS 上的 TUN 需要以 root 身份启动 Nyx。 - tunHintNixos: "在 NixOS 上请以声明方式授予 TUN:设置 programs.nyx.tunMode = true,重建系统,然后重启 Nyx——nyx 命令会解析到带 capability 的包装器。此处运行时 setcap 不会持久化。" - tunGrantedToast: 已授予 TUN 权限 —— 重启 Nyx 生效。 - tunGrantFailed: 授予 TUN 权限失败 svcStatus: 状态 svcRunning: 运行中 svcStopped: 已停止 @@ -207,6 +202,10 @@ pages: svcStart: 启动 svcStop: 停止 svcRestart: 重启 + svcStale: 需要修复 + svcRepair: 修复… + svcHint: Nyx 通过系统服务运行 mihomo 内核,这正是 TUN 模式能够创建网络设备的原因。安装、启动、停止或删除服务需要管理员权限。 + svcManagedHint: 该服务由你的系统配置提供,因此 Nyx 不会安装或卸载它。请在系统配置中管理。 sniffer: 嗅探器 snifferOverrideDest: 覆盖目标地址 snifferForceDns: 强制 DNS 映射 @@ -260,9 +259,11 @@ pages: spBypass: 绕过 alwaysOnTop: 置顶 disableTray: 禁用托盘图标 + systemWindowFrame: 使用系统窗口边框 trayNodeInfo: 在托盘显示节点信息 takeOverDns: 接管 DNS takeOverSniffer: 接管嗅探器 + connectivityHint: TUN 与系统代理不能同时启用,开启其一会关闭另一个。 overrideSettings: 覆盖订阅设置 overrideHint: 关闭时使用当前订阅的值(若订阅未提供则使用默认值)。开启以强制应用这些设置。 networkDetection: 网络变化检测 @@ -276,6 +277,8 @@ pages: scQuitKeepCore: 退出(保留内核) scHint: 点击一个快捷键,然后按下按键。Esc 取消,Backspace 清除。 scPress: 按下按键… + scWaylandTitle: Wayland 下无法使用全局快捷键。 + scWaylandHint: 'Wayland 没有全局按键抓取协议,快捷键由合成器管理。请改为在合成器配置中绑定下列链接,并把链接作为参数启动 Nyx。niri 示例: Mod+Shift+T { spawn "nyx" "nyx://toggle-tun"; }' tooltips: refresh: 刷新 toggleStats: 切换统计 @@ -284,9 +287,6 @@ tooltips: testLatency: 测试延迟 activate: 启用 update: 更新 - edit: 编辑 - editInfo: 编辑信息 - delete: 删除 configure: 配置 unfix: 重置为自动 restartConnections: 重启连接 @@ -308,11 +308,8 @@ onboarding: profileBody: 导入订阅链接或本地配置文件——这是您的代理来源。 profileHint: 使用右上角的“添加”按钮。 serviceTitle: 安装服务 - serviceBody: 后台服务让 Nyx 无需管理员提示即可管理内核和 TUN 模式。 - serviceHint: 打开 Mihomo 设置并安装服务。 - tunTitle: 选择流量路由方式 - tunBody: "有两种路由方式。系统代理无需权限,但仅覆盖读取桌面/代理设置的应用。TUN/VPN 模式覆盖整个设备,但内核需要网络权限。" - tunHint: "在 GNOME 或 Windows 上系统代理通常已足够;在其他 Linux 桌面环境请使用 TUN 以获得全面覆盖。" + serviceBody: "Nyx 通过系统服务运行 mihomo 内核——正是它为 TUN 模式提供所需的网络权限。安装时会要求输入一次密码。" + serviceHint: "打开 设置 → Mihomo → 系统服务 并安装。" proxyTitle: 开启代理 proxyBody: 在主页切换主开关以转发您的流量。 proxyHint: 使用主页的主开关。 @@ -328,5 +325,11 @@ tray: connect: 连接 disconnect: 断开 restartCore: 重启内核 - quitNoCore: 退出(保留内核) quit: 退出 +core: + failure: + coreMissing: mihomo 内核不可用 + configInvalid: 内核拒绝了该配置 + serviceUnavailable: Nyx 服务不可用 + timeout: 内核启动超时 + other: 无法启动内核 diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..3b61272 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,84 @@ +{ self }: +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.programs.nyx; + caps = [ + "CAP_NET_ADMIN" + "CAP_NET_RAW" + "CAP_NET_BIND_SERVICE" + "CAP_SYS_TIME" + "CAP_SYS_PTRACE" + "CAP_DAC_READ_SEARCH" + "CAP_DAC_OVERRIDE" + "CAP_CHOWN" + "CAP_FOWNER" + ]; +in +{ + options.programs.nyx = { + enable = lib.mkEnableOption "Nyx, a desktop GUI for the Mihomo proxy core"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.nyx; + defaultText = lib.literalExpression "nyx.packages.\${system}.nyx"; + description = "The Nyx package to install."; + }; + + service = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Run the privileged mihomo supervisor as a declarative unit. Nyx then + never asks polkit for anything, and TUN mode works out of the box. + ''; + }; + + user = lib.mkOption { + type = lib.types.str; + example = "alice"; + description = '' + The only user allowed to drive the supervisor over its socket. This is + the account you run the Nyx GUI from. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + + systemd.services.nyx = lib.mkIf cfg.service.enable { + description = "Nyx Service (mihomo core supervisor)"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + "NetworkManager.service" + "systemd-networkd.service" + "iwd.service" + ]; + serviceConfig = { + Type = "simple"; + ExecStart = "${lib.getExe cfg.package} --nyx-service --nyx-service-owner ${cfg.service.user}"; + Restart = "always"; + RestartSec = 2; + RuntimeDirectory = "nyx"; + RuntimeDirectoryMode = "0755"; + LimitNPROC = 500; + LimitNOFILE = 1000000; + CapabilityBoundingSet = caps; + AmbientCapabilities = caps; + }; + }; + + environment.etc."nyx/service-managed" = lib.mkIf cfg.service.enable { + text = "nixos\n"; + }; + }; +} diff --git a/src/app/actions.rs b/src/app/actions.rs index fd54222..5e29ad2 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,5 +1,5 @@ use gpui::{App, AppContext, Global, WindowHandle}; -use gpui_component::{notification::Notification, Root, WindowExt}; +use gpui_component::{Root, WindowExt, notification::Notification}; use serde_json::json; use crate::app::runtime; @@ -9,14 +9,11 @@ use crate::backend; struct MainWindow(WindowHandle); impl Global for MainWindow {} -/// Records the main window handle so actions can show/focus it. pub fn set_main_window(handle: WindowHandle, cx: &mut App) { cx.set_global(MainWindow(handle)); } -/// Shows a toast on the main window. Uses `update_window` (not -/// `WindowHandle::update`) so it doesn't lock `Root`, which `push_notification` -/// updates itself. +/// Toasts on the main window via `update_window`, which avoids locking `Root`. pub fn notify(note: Notification, cx: &mut App) { if AppState::global(cx).read(cx).onboarding_active { return; @@ -29,20 +26,18 @@ pub fn notify(note: Notification, cx: &mut App) { }); } -/// Brings the main window to the foreground (un-hiding it first if it was -/// closed to the tray). +/// Brings the main window up, recreating it if it was closed to the tray. pub fn show_window(cx: &mut App) { cx.activate(true); #[cfg(windows)] { - // `spawn` (not `defer`) so the Win32 ShowWindow runs outside this gpui - // flush — otherwise its WM_* messages re-enter a live borrow and panic. + // `spawn`, not `defer`: the Win32 call must land outside this gpui flush, + // or its WM_* messages re-enter a live borrow and panic. cx.spawn(async move |_cx| crate::app::window::show_now()) .detach(); } #[cfg(not(windows))] { - // The window may have been removed when closed to the tray; recreate it. let shown = cx.try_global::().map(|m| m.0).is_some_and(|h| { h.update(cx, |_root, window, _cx| window.activate_window()) .is_ok() @@ -53,12 +48,10 @@ pub fn show_window(cx: &mut App) { } } -/// Toggles the main window: closes it to the tray if open, otherwise recreates -/// and shows it. Backs the "toggle window" hotkey. +/// Closes the window to the tray if open, otherwise recreates it. pub fn toggle_window(cx: &mut App) { #[cfg(windows)] { - // Run the Win32 calls on the next foreground tick — see `show_window`. cx.spawn(async move |_cx| crate::app::window::toggle_now()) .detach(); } @@ -78,8 +71,7 @@ pub fn toggle_window(cx: &mut App) { } } -/// Switches a proxy group's selection (from the tray), then refreshes groups so -/// the UI and tray check-marks update. +/// Switches a group's selection from the tray, then refreshes so check-marks follow. pub fn set_proxy(group: String, node: String, cx: &mut App) { cx.spawn(async move |cx| { let (g, n) = (group.clone(), node.clone()); @@ -89,7 +81,6 @@ pub fn set_proxy(group: String, node: String, cx: &mut App) { .detach(); } -/// Optimistically switches proxy mode and patches the controlled config. pub fn set_mode(mode: &'static str, cx: &mut App) { AppState::global(cx).update(cx, |st, c| st.set_mode(mode, c)); runtime::detach(async move { @@ -97,54 +88,162 @@ pub fn set_mode(mode: &'static str, cx: &mut App) { }); } -/// Flips the TUN main switch (optimistic UI + controlled-config patch). -pub fn toggle_tun(cx: &mut App) { - let new = !AppState::global(cx).read(cx).tun_enabled; - AppState::global(cx).update(cx, |st, c| st.set_tun_enabled(new, c)); - crate::app::tray::rebuild(cx); - runtime::detach(async move { - let _ = backend::config::patch_controled_mihomo_config(json!({ "tun": { "enable": new } })) - .await; - let _ = backend::config::patch_app_config(json!({ "lastConnected": new })).await; - }); -} +pub const MODE_TUN: &str = "tun"; +pub const MODE_SYSPROXY: &str = "sysproxy"; -/// Sets the system-proxy flag, persists it, and applies/removes the OS proxy. -pub fn set_sysproxy(enable: bool, cx: &mut App) { - let affect_vpn = AppState::global(cx) +pub fn connection_mode(cx: &App) -> &'static str { + match AppState::global(cx) .read(cx) - .app_flag("affectVPNConnections"); + .app_config + .get("connectionMode") + .and_then(|v| v.as_str()) + { + Some(MODE_SYSPROXY) => MODE_SYSPROXY, + _ => MODE_TUN, + } +} + +pub fn connected(cx: &App) -> bool { + let st = AppState::global(cx).read(cx); + st.tun_enabled || st.app_flag("sysProxy.enable") +} + +pub fn set_connection(mode: &'static str, on: bool, cx: &mut App) { + let tun = on && mode == MODE_TUN; + let sysproxy = on && mode == MODE_SYSPROXY; + let (affect_vpn, running) = { + let st = AppState::global(cx).read(cx); + ( + st.app_flag("affectVPNConnections"), + st.core_status.is_running(), + ) + }; + AppState::global(cx).update(cx, |st, c| { - if let Some(obj) = st.app_config.as_object_mut() { - let sp = obj.entry("sysProxy").or_insert_with(|| json!({})); - if let Some(spo) = sp.as_object_mut() { - spo.insert("enable".into(), json!(enable)); + st.set_tun_enabled(tun, c); + st.set_app_value("sysProxy.enable", json!(sysproxy), c); + if on { + st.set_app_value("connectionMode", json!(mode), c); + } + }); + crate::app::tray::rebuild(cx); + + cx.spawn(async move |cx: &mut gpui::AsyncApp| { + let mut app_patch = json!({ "lastConnected": on, "sysProxy": { "enable": sysproxy } }); + if on && let Some(obj) = app_patch.as_object_mut() { + obj.insert("connectionMode".into(), json!(mode)); + } + let _ = runtime::spawn(backend::config::patch_app_config(app_patch)).await; + + let patch = if tun { + json!({ "tun": { "enable": true }, "dns": { "enable": true } }) + } else { + json!({ "tun": { "enable": false } }) + }; + let _ = runtime::spawn(backend::config::patch_controled_mihomo_config(patch)).await; + + if on && !running { + // Starting the core also applies the system proxy and refreshes state. + if !crate::app::bootstrap::start_core_and_streams(cx, tun).await { + cx.update(|cx| { + AppState::global(cx).update(cx, |st, c| { + st.set_tun_enabled(false, c); + st.set_app_value("sysProxy.enable", json!(false), c); + }); + crate::app::tray::rebuild(cx); + }); } - c.notify(); + return; } + + let _ = runtime::spawn(backend::sysproxy::apply(sysproxy, affect_vpn)).await; + crate::app::bootstrap::refresh_runtime_data(cx).await; + }) + .detach(); +} + +pub fn toggle_connection(cx: &mut App) { + let mode = connection_mode(cx); + let on = !connected(cx); + set_connection(mode, on, cx); +} + +pub fn select_connection_mode(mode: &'static str, cx: &mut App) { + if connection_mode(cx) == mode { + return; + } + if connected(cx) { + set_connection(mode, true, cx); + return; + } + AppState::global(cx).update(cx, |st, c| { + st.set_app_value("connectionMode", json!(mode), c) }); runtime::detach(async move { - let _ = - backend::config::patch_app_config(json!({ "sysProxy": { "enable": enable } })).await; - backend::sysproxy::apply(enable, affect_vpn).await; + let _ = backend::config::patch_app_config(json!({ "connectionMode": mode })).await; }); } -/// Flips the system-proxy enable flag (hotkey / tray). +pub fn toggle_tun(cx: &mut App) { + let on = !AppState::global(cx).read(cx).tun_enabled; + set_connection(MODE_TUN, on, cx); +} + +pub fn set_sysproxy(enable: bool, cx: &mut App) { + set_connection(MODE_SYSPROXY, enable, cx); +} + pub fn toggle_sysproxy(cx: &mut App) { - let new = !AppState::global(cx).read(cx).app_flag("sysProxy.enable"); - set_sysproxy(new, cx); + let on = !AppState::global(cx).read(cx).app_flag("sysProxy.enable"); + set_connection(MODE_SYSPROXY, on, cx); +} + +pub async fn mark_disconnected(cx: &mut gpui::AsyncApp) { + let _ = runtime::spawn(async { + let _ = backend::config::patch_app_config(json!({ "sysProxy": { "enable": false } })).await; + backend::sysproxy::clear(); + }) + .await; + cx.update(|cx| { + AppState::global(cx).update(cx, |st, c| { + st.set_core_status(crate::app::state::CoreStatus::Stopped, c); + st.set_tun_enabled(false, c); + st.set_app_value("sysProxy.enable", json!(false), c); + }); + crate::app::tray::rebuild(cx); + }); } -/// Restarts the mihomo core (fire-and-forget). pub fn restart_core(_cx: &mut App) { runtime::detach(async { - let _ = backend::manager::restart_core().await; + let _ = backend::core::restart().await; + }); +} + +const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_millis(1500); + +/// The single quit path: un-proxy the machine but leave the service and core up, +/// so reopening restores the previous state without another prompt. Runs on the +/// UI thread, so every step is bounded — a wedged core must not freeze the window. +pub fn shutdown_and_quit(_cx: &mut App) { + let started = std::time::Instant::now(); + backend::sysproxy::clear(); + let dropped_tun = runtime::runtime().block_on(async { + tokio::time::timeout( + SHUTDOWN_GRACE, + backend::config::patch_controled_mihomo_config(json!({ "tun": { "enable": false } })), + ) + .await }); + if dropped_tun.is_err() { + log::warn!("[quit] the core did not answer in time, leaving TUN to the next start"); + } + log::info!("[quit] shutdown took {}ms", started.elapsed().as_millis()); + std::process::exit(0); } -/// Relaunches the app executable, then quits this instance. The relaunch flag -/// tells the new process to wait for this one to release the single-instance lock. +/// Relaunches the executable; the flag makes the new process wait for the +/// single-instance lock. pub fn restart_app(cx: &mut App) { if let Ok(exe) = std::env::current_exe() { let _ = std::process::Command::new(exe) @@ -153,26 +252,3 @@ pub fn restart_app(cx: &mut App) { } cx.quit(); } - -/// Quits, leaving the core/service running. -pub fn quit_without_core(cx: &mut App) { - cx.quit(); -} - -/// Clears the OS system proxy, stops the core completely (service-managed or -/// local, best-effort, blocking), then quits. -pub fn quit_with_core(cx: &mut App) { - backend::sysproxy::clear(); - let _ = runtime::runtime().block_on(backend::service::stop_core_complete()); - cx.quit(); -} - -/// Ctrl+close: turns the proxy off and clears the system proxy, but leaves the -/// core running in the background. -pub fn disconnect_and_quit(cx: &mut App) { - backend::sysproxy::clear(); - let _ = runtime::runtime().block_on(backend::config::patch_controled_mihomo_config( - json!({ "tun": { "enable": false } }), - )); - cx.quit(); -} diff --git a/src/app/app_icon.rs b/src/app/app_icon.rs index e2e3e42..b53bf50 100644 --- a/src/app/app_icon.rs +++ b/src/app/app_icon.rs @@ -15,7 +15,6 @@ mod cache { RefCell::new(HashMap::new()); } - /// Returns the cached icon for `path`, extracting + caching on first use. pub(super) fn get(path: &str) -> Option> { if let Some(hit) = CACHE.with(|c| c.borrow().get(path).cloned()) { return hit; @@ -26,7 +25,6 @@ mod cache { } } -/// The executable's icon as a gpui image, or `None` if unavailable. #[cfg(windows)] pub fn for_path(path: &str) -> Option> { if path.trim().is_empty() { @@ -42,14 +40,14 @@ pub fn for_path(_path: &str) -> Option> { #[cfg(windows)] fn extract(path: &str) -> Option { - use windows::core::PCWSTR; use windows::Win32::Graphics::Gdi::{ - DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, - BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + BI_RGB, BITMAP, BITMAPINFO, BITMAPINFOHEADER, DIB_RGB_COLORS, DeleteObject, GetDC, + GetDIBits, GetObjectW, ReleaseDC, }; use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES; - use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON}; + use windows::Win32::UI::Shell::{SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON, SHGetFileInfoW}; use windows::Win32::UI::WindowsAndMessaging::{DestroyIcon, GetIconInfo, ICONINFO}; + use windows::core::PCWSTR; let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); let mut info: SHFILEINFOW = unsafe { std::mem::zeroed() }; diff --git a/src/app/assets.rs b/src/app/assets.rs index 367888a..4239932 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -2,7 +2,6 @@ use std::borrow::Cow; use gpui::{AssetSource, Result, SharedString}; -/// Nyx-owned assets embedded from `assets/` at build time (logo, extra icons, flags). #[derive(rust_embed::RustEmbed)] #[folder = "assets"] #[include = "brand/*"] @@ -10,7 +9,6 @@ use gpui::{AssetSource, Result, SharedString}; #[include = "flags/*"] struct NyxEmbed; -/// The asset source registered with the gpui application. pub struct Assets; impl AssetSource for Assets { diff --git a/src/app/autostart.rs b/src/app/autostart.rs index 8703a26..76e4b8d 100644 --- a/src/app/autostart.rs +++ b/src/app/autostart.rs @@ -3,11 +3,7 @@ use auto_launch::AutoLaunchBuilder; fn builder() -> Option { let exe = std::env::current_exe().ok()?; #[cfg(target_os = "linux")] - let path = if crate::backend::elevation::is_nixos() && exe.starts_with("/nix/store") { - "nyx".to_string() - } else { - exe.to_string_lossy().into_owned() - }; + let path = stable_exe_path(&exe); #[cfg(not(target_os = "linux"))] let path = exe.to_string_lossy().into_owned(); AutoLaunchBuilder::new() @@ -18,7 +14,34 @@ fn builder() -> Option { .ok() } -/// Enables or disables launch-on-login to match `enabled`. +#[cfg(target_os = "linux")] +fn stable_exe_path(exe: &std::path::Path) -> String { + let fallback = || exe.to_string_lossy().into_owned(); + if !exe.starts_with("/nix/store") { + return fallback(); + } + let Some(name) = exe.file_name() else { + return fallback(); + }; + let mut dirs_to_try = Vec::new(); + if let Ok(user) = std::env::var("USER") { + dirs_to_try.push(std::path::PathBuf::from(format!( + "/etc/profiles/per-user/{user}/bin" + ))); + } + if let Some(home) = dirs::home_dir() { + dirs_to_try.push(home.join(".nix-profile/bin")); + } + dirs_to_try.push(std::path::PathBuf::from("/run/current-system/sw/bin")); + + dirs_to_try + .into_iter() + .map(|dir| dir.join(name)) + .find(|p| p.exists()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(fallback) +} + pub fn set(enabled: bool) { let Some(auto) = builder() else { return; @@ -33,7 +56,6 @@ pub fn set(enabled: bool) { } } -/// Reconciles the OS autostart entry with the desired flag (called on startup). pub fn sync(enabled: bool) { let Some(auto) = builder() else { return; diff --git a/src/app/bootstrap.rs b/src/app/bootstrap.rs index 6b1c697..59ed317 100644 --- a/src/app/bootstrap.rs +++ b/src/app/bootstrap.rs @@ -1,148 +1,104 @@ use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use gpui::{App, AsyncApp}; use crate::app::runtime; use crate::app::state::{self, AppState, CoreStatus}; use crate::backend; +use crate::backend::core::ServiceStatus; /// Streams are long-lived reconnect loops — wire them at most once per process. static STREAMS_STARTED: AtomicBool = AtomicBool::new(false); -/// On launch: seed config, prefetch the binary, and — if setup is complete — -/// start the core (with TUN forced off, so opening the app never connects). +const WATCH_INTERVAL: Duration = Duration::from_secs(5); +const RETRY_MIN: Duration = Duration::from_secs(5); +const RETRY_MAX: Duration = Duration::from_secs(300); + +/// On launch: seed config, prefetch the binary, then start the core with the +/// connection state from the last session. pub fn spawn_backend_startup(cx: &mut App) { cx.spawn(async move |cx: &mut AsyncApp| { - let _ = runtime::spawn(async { backend::startup::ensure_default_app_config() }).await; + let _ = runtime::spawn(async { + backend::startup::ensure_default_app_config(); + backend::startup::normalize_connection_mode().await; + }) + .await; prefetch_core_binary(); - refresh_profiles(cx).await; - import_declared_profiles(cx).await; - - if can_autostart_core().await { - // Restore the proxy connection if it was on when we last exited, - // otherwise start the core idle. - let connected = backend::config::app_config_bool("lastConnected"); - let started = if connected { - start_core_connected(cx).await - } else { - start_core_disconnected(cx).await - }; - if !started { - retry_core_autostart(cx, connected).await; - } + + if !can_autostart_core().await { + cx.update(|cx| { + AppState::global(cx) + .update(cx, |st, cx| st.set_core_status(CoreStatus::Stopped, cx)); + }); + refresh_runtime_data(cx).await; return; } - cx.update(|cx| { - AppState::global(cx).update(cx, |st, cx| st.set_core_status(CoreStatus::Stopped, cx)); - }); - refresh_runtime_data(cx).await; + start_core_and_streams(cx, restore_tun()).await; + watch_core(cx).await; }) .detach(); } -const AUTOSTART_RETRY_DELAYS: [u64; 8] = [5, 10, 20, 30, 60, 60, 120, 120]; - -async fn retry_core_autostart(cx: &mut AsyncApp, connected: bool) { - for delay in AUTOSTART_RETRY_DELAYS { - cx.background_executor() - .timer(std::time::Duration::from_secs(delay)) - .await; - - let mut still_failed = false; - cx.update(|cx| { - still_failed = matches!( - AppState::global(cx).read(cx).core_status, - CoreStatus::Failed(_) - ); - }); - if !still_failed { - return; - } - - log::info!("[bootstrap] retrying core autostart (waited {delay}s)"); - let started = if connected { - start_core_connected(cx).await - } else { - start_core_disconnected(cx).await - }; - if started { - return; +/// The single restart owner: revives a dead core and retries a failed start +/// with a growing delay. +async fn watch_core(cx: &mut AsyncApp) { + let mut backoff = RETRY_MIN; + let mut waited = Duration::ZERO; + + loop { + cx.background_executor().timer(WATCH_INTERVAL).await; + + let mut status = CoreStatus::Stopped; + cx.update(|cx| status = AppState::global(cx).read(cx).core_status.clone()); + + match status { + CoreStatus::Running => { + waited = Duration::ZERO; + backoff = RETRY_MIN; + if runtime::spawn(backend::core::is_alive()).await == Ok(false) { + log::warn!("[watchdog] the core is gone, restarting it"); + restart_core(cx).await; + } + } + CoreStatus::Failed { .. } => { + waited += WATCH_INTERVAL; + if waited < backoff { + continue; + } + waited = Duration::ZERO; + backoff = (backoff * 2).min(RETRY_MAX); + log::info!("[watchdog] retrying core start"); + restart_core(cx).await; + } + _ => {} } } - log::error!("[bootstrap] core autostart gave up after retries"); } -/// Core may be started unattended only once a profile exists and the runtime is -/// available (Windows service installed / core binary present). -async fn can_autostart_core() -> bool { - if !has_any_profile().await { - return false; - } - matches!( - runtime::spawn(backend::service::service_status()).await, - Ok(Ok(s)) if s != "not-installed" - ) +async fn restart_core(cx: &mut AsyncApp) { + start_core_and_streams(cx, restore_tun()).await; } -/// Imports remote profiles declared in the `NYX_PROFILES` env var -async fn import_declared_profiles(cx: &mut AsyncApp) { - let mut urls: Vec = Vec::new(); - - if let Ok(spec) = std::env::var("NYX_PROFILES") { - urls.extend(spec.split_whitespace().map(str::to_string)); - } - - if let Ok(path) = std::env::var("NYX_PROFILES_FILE") { - match std::fs::read_to_string(&path) { - Ok(contents) => urls.extend(contents.split_whitespace().map(str::to_string)), - Err(e) => log::warn!("[profiles] cannot read NYX_PROFILES_FILE '{path}': {e}"), - } - } - if urls.is_empty() { - return; - } - - let mut existing_urls = std::collections::HashSet::new(); - let mut has_current = false; - if let Ok(Ok(cfg)) = runtime::spawn(backend::config::get_profile_config()).await { - if let Some(items) = cfg.get("items").and_then(|v| v.as_array()) { - for it in items { - if let Some(url) = it.get("url").and_then(|v| v.as_str()) { - existing_urls.insert(url.to_string()); - } - } - } - has_current = cfg - .get("current") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.is_empty()); - } +pub fn restore_tun() -> bool { + backend::config::app_config_bool("lastConnected") + && backend::config::app_config_str("connectionMode", "tun") == "tun" +} - let mut first_added: Option = None; - for url in urls { - if !existing_urls.insert(url.clone()) { - continue; - } - let item = serde_json::json!({ "type": "remote", "url": url }); - match runtime::spawn(backend::config::add_profile_item(item)).await { - Ok(Ok(id)) => { - log::info!("[profiles] imported declared profile {url}"); - first_added.get_or_insert(id); - } - Ok(Err(e)) => log::warn!("[profiles] failed to import {url}: {e}"), - Err(_) => {} - } +/// Unattended start needs a profile plus a runtime — the service, or direct mode. +async fn can_autostart_core() -> bool { + if !has_any_profile().await { + return false; } - - if !has_current { - if let Some(id) = first_added { - let _ = runtime::spawn(backend::config::change_current_profile(id)).await; - } + if backend::config::app_config_str("corePermissionMode", "service") == "direct" { + return true; } - - refresh_profiles(cx).await; + !matches!( + runtime::spawn(backend::core::service_status()).await, + Ok(ServiceStatus::NotInstalled) + ) } async fn has_any_profile() -> bool { @@ -156,23 +112,15 @@ async fn has_any_profile() -> bool { ) } -pub async fn start_core_disconnected(cx: &mut AsyncApp) -> bool { - let _ = runtime::spawn(backend::config::patch_controled_mihomo_config( - serde_json::json!({ "tun": { "enable": false } }), - )) - .await; - start_core_and_streams(cx).await -} - -pub async fn start_core_connected(cx: &mut AsyncApp) -> bool { - let _ = runtime::spawn(backend::config::patch_controled_mihomo_config( - serde_json::json!({ "tun": { "enable": true }, "dns": { "enable": true } }), - )) - .await; - start_core_and_streams(cx).await -} +pub async fn start_core_and_streams(cx: &mut AsyncApp, connected: bool) -> bool { + let tun = serde_json::json!({ "tun": { "enable": connected } }); + let patch = if connected { + serde_json::json!({ "tun": { "enable": true }, "dns": { "enable": true } }) + } else { + tun + }; + let _ = runtime::spawn(backend::config::patch_controled_mihomo_config(patch)).await; -pub async fn start_core_and_streams(cx: &mut AsyncApp) -> bool { cx.update(|cx| { AppState::global(cx).update(cx, |st, cx| st.set_core_status(CoreStatus::Starting, cx)); }); @@ -182,20 +130,23 @@ pub async fn start_core_and_streams(cx: &mut AsyncApp) -> bool { cx.update(|cx| { AppState::global(cx).update(cx, |st, cx| match &outcome { Ok(Ok(())) => st.set_core_status(CoreStatus::Running, cx), - Ok(Err(e)) => st.set_core_status(CoreStatus::Failed(e.clone().into()), cx), - Err(_) => { - st.set_core_status(CoreStatus::Failed("startup task was cancelled".into()), cx) - } + Ok(Err(e)) => st.set_core_status(e.clone().into(), cx), + Err(_) => st.set_core_status( + backend::core::CoreError::new( + backend::core::FailureKind::Other, + "the startup task was cancelled", + ) + .into(), + cx, + ), }); }); if !started { - log::error!("[bootstrap] core failed to start: {outcome:?}"); + log::error!("[bootstrap] core failed to start"); return false; } - wait_for_core_ready().await; - if !STREAMS_STARTED.swap(true, Ordering::SeqCst) { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -232,20 +183,6 @@ pub async fn start_core_and_streams(cx: &mut AsyncApp) -> bool { true } -/// Polls the core's HTTP controller until it answers — mihomo needs a moment to -/// bind after spawn, else the first groups/version fetch comes back empty. -async fn wait_for_core_ready() { - let _ = runtime::spawn(async { - for _ in 0..40 { - if backend::api::get_version().await.is_ok() { - return; - } - tokio::time::sleep(std::time::Duration::from_millis(150)).await; - } - }) - .await; -} - fn prefetch_core_binary() { let core = backend::config::app_config_str("core", "mihomo"); if core == "system" { @@ -262,7 +199,6 @@ fn prefetch_core_binary() { }); } -/// Re-fetches groups, TUN state, mihomo version, and the current profile name. pub async fn refresh_runtime_data(cx: &mut AsyncApp) { if let Ok(Ok(groups_val)) = runtime::spawn(backend::mihomo::groups()).await { cx.update(|cx| { @@ -316,14 +252,16 @@ pub async fn refresh_runtime_data(cx: &mut AsyncApp) { }); } - if let Ok(Ok(version)) = runtime::spawn(backend::api::get_version()).await { - cx.update(|cx| { - AppState::global(cx).update(cx, |st, c| { - st.mihomo_version = Some(version.into()); - c.notify(); - }); + let version = runtime::spawn(backend::api::get_version()).await; + cx.update(|cx| { + AppState::global(cx).update(cx, |st, c| { + st.mihomo_version = match &version { + Ok(Ok(v)) => Some(v.clone().into()), + _ => None, + }; + c.notify(); }); - } + }); refresh_profiles(cx).await; } @@ -343,7 +281,6 @@ pub async fn refresh_profiles(cx: &mut AsyncApp) { } } -/// Returns the full JSON of the currently selected profile item. fn current_profile_item(pcfg: &serde_json::Value) -> Option { let current = pcfg.get("current").and_then(|v| v.as_str())?; pcfg.get("items") @@ -353,7 +290,6 @@ fn current_profile_item(pcfg: &serde_json::Value) -> Option { .cloned() } -/// Resolves the display name of the currently selected profile. fn current_profile_name(pcfg: &serde_json::Value) -> Option { let current = pcfg.get("current").and_then(|v| v.as_str())?; let items = pcfg.get("items").and_then(|v| v.as_array())?; diff --git a/src/app/deep_link.rs b/src/app/deep_link.rs index f32363f..28278db 100644 --- a/src/app/deep_link.rs +++ b/src/app/deep_link.rs @@ -11,8 +11,8 @@ use crate::backend; /// Registers the `nyx://` URI scheme so the OS launches this exe with the URL. Idempotent. #[cfg(windows)] pub fn register_scheme() { - use winreg::enums::HKEY_CURRENT_USER; use winreg::RegKey; + use winreg::enums::HKEY_CURRENT_USER; let Ok(exe) = std::env::current_exe() else { return; @@ -32,8 +32,7 @@ pub fn register_scheme() { } } -/// Linux: install a `.desktop` entry claiming `x-scheme-handler/nyx` and set it -/// as the default handler. Idempotent. +/// Linux: install a `.desktop` claiming `x-scheme-handler/nyx`. Idempotent. #[cfg(target_os = "linux")] pub fn register_scheme() { let Ok(exe) = std::env::current_exe() else { @@ -67,10 +66,8 @@ pub fn register_scheme() { .status(); } -/// Sets `nyx-url.desktop` as the `x-scheme-handler/nyx` handler by editing -/// `~/.config/mimeapps.list` ourselves. The system `xdg-mime` helper writes -/// its temp file next to the first `mimeapps.list` it finds, which on NixOS is -/// a read-only `/nix/store` path, so it fails there. +/// Edits `~/.config/mimeapps.list` ourselves: `xdg-mime` writes its temp file +/// beside the first `mimeapps.list` it finds, which on NixOS is read-only. #[cfg(target_os = "linux")] fn set_default_handler() { let Some(path) = dirs::config_dir().map(|d| d.join("mimeapps.list")) else { @@ -130,20 +127,20 @@ fn upsert_default_application(contents: &str, key: &str, value: &str) -> String #[cfg(not(any(windows, target_os = "linux")))] pub fn register_scheme() {} -/// Starts the deep-link drain loop on the gpui main thread, consuming URLs from `rx`. pub fn start(rx: Receiver, cx: &mut App) { - cx.spawn(async move |cx: &mut AsyncApp| loop { - cx.background_executor() - .timer(Duration::from_millis(150)) - .await; - while let Ok(url) = rx.try_recv() { - cx.update(|cx| handle_url(&url, cx)); + cx.spawn(async move |cx: &mut AsyncApp| { + loop { + cx.background_executor() + .timer(Duration::from_millis(150)) + .await; + while let Ok(url) = rx.try_recv() { + cx.update(|cx| handle_url(&url, cx)); + } } }) .detach(); } -/// Parses and dispatches a single `nyx://` URL. fn handle_url(url: &str, cx: &mut App) { let Ok(parsed) = url::Url::parse(url) else { log::warn!("[deep-link] failed to parse: {url}"); @@ -156,14 +153,30 @@ fn handle_url(url: &str, cx: &mut App) { let params: HashMap = parsed.query_pairs().into_owned().collect(); log::info!("[deep-link] command='{command}' params={params:?}"); - actions::show_window(cx); match command.as_str() { - "install-config" => install_config(params, cx), - other => log::warn!("[deep-link] unknown command '{other}'"), + "install-config" => { + actions::show_window(cx); + install_config(params, cx); + } + "show" => actions::show_window(cx), + "toggle-window" => actions::toggle_window(cx), + "toggle-sysproxy" => actions::toggle_sysproxy(cx), + "toggle-tun" => actions::toggle_tun(cx), + "mode" => match params.get("value").map(String::as_str) { + Some("rule") => actions::set_mode("rule", cx), + Some("global") => actions::set_mode("global", cx), + Some("direct") => actions::set_mode("direct", cx), + other => log::warn!("[deep-link] mode: bad value {other:?}"), + }, + "restart" => actions::restart_app(cx), + "quit" => actions::shutdown_and_quit(cx), + other => { + log::warn!("[deep-link] unknown command '{other}'"); + actions::show_window(cx); + } } } -/// Adds a remote profile from `nyx://install-config?url=…` and activates it. fn install_config(params: HashMap, cx: &mut App) { let Some(config_url) = params.get("url").cloned() else { log::warn!("[deep-link] install-config: missing 'url'"); diff --git a/src/app/hotkeys.rs b/src/app/hotkeys.rs index a668fb2..018b602 100644 --- a/src/app/hotkeys.rs +++ b/src/app/hotkeys.rs @@ -1,13 +1,12 @@ use std::collections::HashMap; use std::time::Duration; -use global_hotkey::{hotkey::HotKey, GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState, hotkey::HotKey}; use gpui::{App, AsyncApp, Global}; use crate::app::actions; use crate::app::state::AppState; -/// (app-config key, internal action id). const BINDINGS: &[(&str, &str)] = &[ ("showWindowShortcut", "show"), ("triggerSysProxyShortcut", "sysproxy"), @@ -26,8 +25,24 @@ struct Hotkeys { } impl Global for Hotkeys {} -/// Builds the hotkey manager and starts the gpui event-drain loop. +/// Global hotkeys need an X11 connection, so user must use nyx:// deep links in Wayland sessions. +pub fn supported() -> bool { + #[cfg(target_os = "linux")] + { + std::env::var_os("WAYLAND_DISPLAY").is_none() + && std::env::var("XDG_SESSION_TYPE").as_deref() != Ok("wayland") + } + #[cfg(not(target_os = "linux"))] + { + true + } +} + pub fn init(cx: &mut App) { + if !supported() { + log::info!("[hotkeys] Wayland session — global hotkeys unavailable, use nyx:// deep links"); + return; + } let manager = match GlobalHotKeyManager::new() { Ok(m) => m, Err(e) => { @@ -58,7 +73,6 @@ pub fn init(cx: &mut App) { .detach(); } -/// Re-registers all hotkeys from the current app config. Safe to call on every config change. pub fn reload(cx: &mut App) { if cx.try_global::().is_none() { return; @@ -107,7 +121,7 @@ fn dispatch(id: u32, cx: &mut App) { Some("global") => actions::set_mode("global", cx), Some("direct") => actions::set_mode("direct", cx), Some("restart-app") => actions::restart_app(cx), - Some("quit-nc") => actions::quit_without_core(cx), + Some("quit-nc") => actions::shutdown_and_quit(cx), _ => {} } } diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 7487f64..bd7434a 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -6,7 +6,6 @@ use tokio::sync::oneshot; static RUNTIME: OnceLock = OnceLock::new(); -/// The process-wide tokio runtime (lazily built on first use). pub fn runtime() -> &'static Runtime { RUNTIME.get_or_init(|| { tokio::runtime::Builder::new_multi_thread() @@ -16,7 +15,6 @@ pub fn runtime() -> &'static Runtime { }) } -/// Spawns `fut` on the tokio runtime and returns an awaitable receiver for its result. pub fn spawn(fut: F) -> oneshot::Receiver where T: Send + 'static, @@ -29,7 +27,6 @@ where rx } -/// Fire-and-forget variant for backend work whose result the UI doesn't need. pub fn detach(fut: F) where F: Future + Send + 'static, diff --git a/src/app/scheduler.rs b/src/app/scheduler.rs index b6062d1..41e2686 100644 --- a/src/app/scheduler.rs +++ b/src/app/scheduler.rs @@ -11,8 +11,7 @@ const CHECK_INTERVAL_SECS: u64 = 300; const INITIAL_DELAY_SECS: u64 = 15; const EXPIRY_WARN_SECS: i64 = 3 * 86_400; -/// Starts the scheduler: a one-time quota/expiry check after startup, then a -/// recurring sweep of due remote-profile auto-updates. +/// A one-time quota/expiry check after startup, then a recurring auto-update sweep. pub fn init(cx: &mut App) { cx.spawn(async move |cx: &mut AsyncApp| { cx.background_executor() diff --git a/src/app/single_instance.rs b/src/app/single_instance.rs index 870aeba..532febe 100644 --- a/src/app/single_instance.rs +++ b/src/app/single_instance.rs @@ -8,9 +8,8 @@ const PORT: u16 = 47654; /// Arg telling the relaunched process to wait for the dying instance to free the port. pub const RELAUNCH_FLAG: &str = "--nyx-relaunch"; -/// Acquires the single-instance lock, returning the bound listener for the -/// primary instance, or `None` if another instance owns it (deep link already -/// forwarded; caller must exit). +/// Returns the bound listener for the primary instance, or `None` when another +/// instance owns it (the deep link is already forwarded, so the caller exits). pub fn acquire_or_forward() -> Option { let relaunch = std::env::args().any(|a| a == RELAUNCH_FLAG); // A relaunch (restart) races the dying instance for the port; wait it out. @@ -34,9 +33,8 @@ pub fn acquire_or_forward() -> Option { } } -/// Sends our `nyx://` argument to the primary instance. With no deep link -/// (a plain relaunch from the launcher), asks it to show its window — which on -/// Linux was closed to the tray and needs recreating. +/// Forwards our `nyx://` argument, or — with no deep link — asks the primary +/// instance to show its window, which on Linux may need recreating. fn forward_deep_link() { let url = deep_link_arg().unwrap_or_else(|| "nyx://show".to_string()); if let Ok(mut stream) = TcpStream::connect(("127.0.0.1", PORT)) { @@ -44,12 +42,10 @@ fn forward_deep_link() { } } -/// The first `nyx://…` value among the process arguments, if present. pub fn deep_link_arg() -> Option { std::env::args().find(|a| a.starts_with("nyx://")) } -/// Spawns the acceptor that pushes forwarded deep-link URLs onto `tx`. pub fn serve(listener: TcpListener, tx: std::sync::mpsc::Sender) { std::thread::spawn(move || { for stream in listener.incoming() { diff --git a/src/app/state.rs b/src/app/state.rs index 282f4ab..a360e09 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -3,6 +3,7 @@ use std::collections::VecDeque; use gpui::{App, AppContext, Context, Entity, Global, SharedString}; use serde_json::Value; +use crate::backend::core::{CoreError, FailureKind}; use crate::backend::{dirs, streaming::StreamEvent}; pub const DEFAULT_LANGUAGE: &str = "en-US"; @@ -14,11 +15,9 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("zh-CN", "简体中文"), ]; -/// Number of per-second traffic samples kept for the Home graph. +/// Per-second traffic samples kept for the Home graph. const MAX_HISTORY: usize = 60; -/// Cap on the in-memory log ring buffer. const MAX_LOGS: usize = 1000; -/// Cap on the remembered recently-closed connections. const MAX_CLOSED: usize = 300; #[derive(Clone, PartialEq, Eq)] @@ -26,16 +25,44 @@ pub enum CoreStatus { Stopped, Starting, Running, - Failed(SharedString), + Failed { + kind: FailureKind, + detail: SharedString, + }, } impl CoreStatus { pub fn is_running(&self) -> bool { matches!(self, CoreStatus::Running) } + + pub fn failed(&self) -> Option<(FailureKind, &SharedString)> { + match self { + CoreStatus::Failed { kind, detail } => Some((*kind, detail)), + _ => None, + } + } +} + +impl From for CoreStatus { + fn from(e: CoreError) -> Self { + CoreStatus::Failed { + kind: e.kind, + detail: e.detail.into(), + } + } +} + +pub fn failure_key(kind: FailureKind) -> &'static str { + match kind { + FailureKind::CoreMissing => "core.failure.coreMissing", + FailureKind::ConfigInvalid => "core.failure.configInvalid", + FailureKind::ServiceUnavailable => "core.failure.serviceUnavailable", + FailureKind::Timeout => "core.failure.timeout", + FailureKind::Other => "core.failure.other", + } } -/// A single proxy node inside a group. #[derive(Clone)] pub struct ProxyNode { pub name: SharedString, @@ -43,7 +70,6 @@ pub struct ProxyNode { pub delay: Option, } -/// A proxy group with its members and the current selection. #[derive(Clone)] pub struct ProxyGroup { pub name: SharedString, @@ -52,7 +78,6 @@ pub struct ProxyGroup { pub all: Vec, } -/// A profile entry (subscription or local file). #[derive(Clone)] pub struct ProfileItem { pub id: SharedString, @@ -67,7 +92,6 @@ pub struct ProfileItem { pub interval: i64, } -/// One log line for the Logs page / ring buffer. #[derive(Clone)] pub struct LogLine { pub time: SharedString, @@ -75,20 +99,16 @@ pub struct LogLine { pub message: SharedString, } -/// Active connections grouped by originating process (Connections page). #[derive(Clone)] pub struct ConnProcess { pub name: SharedString, pub count: usize, pub up: u64, pub down: u64, - /// Executable path of the process (for the app icon), if known. pub process_path: SharedString, - /// Individual connections belonging to this process (for the detail view). pub conns: Vec, } -/// A single active connection (shown in a process's detail view). #[derive(Clone)] pub struct ConnItem { pub id: SharedString, @@ -110,7 +130,6 @@ pub struct ConnItem { pub dns_mode: SharedString, } -/// One routing rule (Rules page). #[derive(Clone)] pub struct Rule { pub kind: SharedString, @@ -118,8 +137,7 @@ pub struct Rule { pub proxy: SharedString, } -/// Shared, observable application state. Views hold the `Entity` -/// (via [`AppState::global`]) and `observe` it to re-render on change. +/// Shared, observable application state; views `observe` it to re-render. pub struct AppState { pub language: SharedString, pub core_status: CoreStatus, @@ -141,19 +159,14 @@ pub struct AppState { pub current_profile_item: Option, pub mode: SharedString, pub app_config: Value, - /// Full controlled mihomo config (used by the TUN settings sub-page). pub controled_config: Value, pub logs: VecDeque, - /// Monotonic count of all log lines ever appended; unlike `logs.len()` it - /// keeps growing past the ring-buffer cap, so the Logs view can autoscroll. + /// Total lines ever appended — keeps growing past the ring cap, so Logs can autoscroll. pub log_seq: usize, pub connections: Vec, - /// Recently-closed connections, built by diffing consecutive `/connections` - /// snapshots. Powers the Connections "Closed" tab. + /// Connections that vanished between two `/connections` snapshots. pub closed_connections: Vec, - /// Last snapshot's active connections keyed by id, to detect closures. active_by_id: std::collections::HashMap, - /// Capped ring of closed (process-name, item) pairs. closed_items: VecDeque<(String, ConnItem)>, pub rules: Vec, } @@ -162,8 +175,7 @@ struct GlobalAppState(Entity); impl Global for GlobalAppState {} impl AppState { - /// Loads persisted state, sets the active locale, and registers the global. - /// Call once after `gpui_component::init`. + /// Loads persisted state, sets the locale, and registers the global. Call once. pub fn init(cx: &mut App) { let language = load_language(); rust_i18n::set_locale(&language); @@ -198,12 +210,10 @@ impl AppState { cx.set_global(GlobalAppState(entity)); } - /// The shared `AppState` entity. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } - /// Switches the UI language, persists it, and notifies observers. pub fn set_language(&mut self, lang: impl Into, cx: &mut Context) { let lang: SharedString = lang.into(); if lang == self.language { @@ -217,6 +227,9 @@ impl AppState { pub fn set_core_status(&mut self, status: CoreStatus, cx: &mut Context) { if self.core_status != status { + if !status.is_running() { + self.mihomo_version = None; + } self.core_status = status; cx.notify(); } @@ -264,7 +277,6 @@ impl AppState { cx.notify(); } - /// Reads a value from the controlled mihomo config by dot path. pub fn ctl(&self, path: &str) -> Option<&Value> { let mut cur = &self.controled_config; for seg in path.split('.') { @@ -273,7 +285,6 @@ impl AppState { Some(cur) } - /// Reads a boolean from the controlled mihomo config (dot path). pub fn ctl_bool(&self, path: &str, default: bool) -> bool { self.ctl(path).and_then(Value::as_bool).unwrap_or(default) } @@ -290,6 +301,29 @@ impl AppState { cur.as_bool().unwrap_or(false) } + pub fn set_app_value(&mut self, path: &str, value: Value, cx: &mut Context) { + if !self.app_config.is_object() { + self.app_config = Value::Object(Default::default()); + } + let (parents, leaf) = path.rsplit_once('.').unwrap_or(("", path)); + let mut cur = &mut self.app_config; + for seg in parents.split('.').filter(|s| !s.is_empty()) { + let Some(obj) = cur.as_object_mut() else { + return; + }; + cur = obj + .entry(seg) + .or_insert_with(|| Value::Object(Default::default())); + if !cur.is_object() { + *cur = Value::Object(Default::default()); + } + } + if let Some(obj) = cur.as_object_mut() { + obj.insert(leaf.to_string(), value); + cx.notify(); + } + } + pub fn set_mode(&mut self, mode: impl Into, cx: &mut Context) { let mode = mode.into(); if self.mode != mode { @@ -298,7 +332,6 @@ impl AppState { } } - /// Updates a single node's delay (after a proxy delay test). pub fn set_node_delay( &mut self, group: &str, @@ -306,15 +339,14 @@ impl AppState { delay: Option, cx: &mut Context, ) { - if let Some(g) = self.groups.iter_mut().find(|g| g.name.as_ref() == group) { - if let Some(n) = g.all.iter_mut().find(|n| n.name.as_ref() == node) { - n.delay = delay; - cx.notify(); - } + if let Some(g) = self.groups.iter_mut().find(|g| g.name.as_ref() == group) + && let Some(n) = g.all.iter_mut().find(|n| n.name.as_ref() == node) + { + n.delay = delay; + cx.notify(); } } - /// Folds one streaming event into the state and notifies observers. pub fn apply_stream_event(&mut self, ev: StreamEvent, cx: &mut Context) { match ev { StreamEvent::Connections(data) => self.apply_connections(&data), @@ -403,14 +435,12 @@ impl AppState { }); } - /// Empties the in-memory log buffer (Logs page "clear" button). pub fn clear_logs(&mut self, cx: &mut Context) { self.logs.clear(); cx.notify(); } } -/// Parses the `{ rules: [...] }` payload from `/rules` into [`Rule`]s. pub fn parse_rules(value: &Value) -> Vec { value .get("rules") @@ -539,8 +569,7 @@ fn parse_conn(c: &Value) -> Option<(String, String, ConnItem)> { Some((id, name, item)) } -/// Groups `(process-name, item)` pairs by process, summing counts + cumulative -/// up/down bytes. Sorted by total traffic (processes and rows within them). +/// Groups `(process, item)` pairs by process, sorted by total traffic. fn group_items(items: impl Iterator) -> Vec { use std::collections::HashMap; let mut map: HashMap = HashMap::new(); @@ -570,7 +599,6 @@ fn group_items(items: impl Iterator) -> Vec Vec { let Some(arr) = value.as_array() else { return Vec::new(); @@ -665,7 +693,6 @@ fn persist_language(lang: &str) { } } -/// Parses `profile.yaml` (`{current, items: [...]}`) into [`ProfileItem`]s. pub fn parse_profiles(cfg: &Value) -> Vec { let current = cfg.get("current").and_then(Value::as_str).unwrap_or(""); cfg.get("items") diff --git a/src/app/tray.rs b/src/app/tray.rs index 8745438..fae7e9e 100644 --- a/src/app/tray.rs +++ b/src/app/tray.rs @@ -7,18 +7,17 @@ use rust_i18n::t; #[cfg(not(target_os = "linux"))] use tray_icon::{ - menu::{CheckMenuItem, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent, + menu::{CheckMenuItem, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, }; use crate::app::actions; use crate::app::state::{AppState, ProxyGroup}; -/// Separator embedded in proxy menu ids (`pxgroupnode`). U+001F won't -/// occur in proxy names. +/// Separator inside proxy menu ids (`pxgroupnode`); U+001F cannot +/// occur in a proxy name. const SEP: char = '\u{1f}'; -/// Decodes the embedded app icon (PNG) into straight RGBA8 + dimensions. fn load_icon_rgba() -> Option<(Vec, u32, u32)> { static PNG: &[u8] = include_bytes!("../../assets/brand/logo.png"); let mut reader = png::Decoder::new(std::io::Cursor::new(PNG)) @@ -111,12 +110,6 @@ fn build_menu(groups: &[ProxyGroup], connected: bool) -> Menu { true, None, )); - let _ = menu.append(&MenuItem::with_id( - "quit-no-core", - &t!("tray.quitNoCore"), - true, - None, - )); let _ = menu.append(&MenuItem::with_id("quit", &t!("tray.quit"), true, None)); menu } @@ -140,17 +133,18 @@ fn build_tray(groups: &[ProxyGroup], connected: bool) -> Option { } } -/// Keeps the `TrayIcon` alive for the lifetime of the app #[cfg(not(target_os = "linux"))] struct GlobalTray(#[allow(dead_code)] TrayIcon); #[cfg(not(target_os = "linux"))] impl gpui::Global for GlobalTray {} -/// Snapshots the tray-relevant slice of app state: proxy groups and whether the -/// proxy is currently connected (TUN on). +/// The tray-relevant slice of state: groups and whether traffic is being routed. fn tray_state(cx: &App) -> (Vec, bool) { let st = AppState::global(cx).read(cx); - (st.groups.clone(), st.tun_enabled) + ( + st.groups.clone(), + st.tun_enabled || st.app_flag("sysProxy.enable"), + ) } #[cfg(not(target_os = "linux"))] @@ -164,7 +158,6 @@ fn create_icon(cx: &mut App) { } } -/// Rebuilds the tray menu from current state #[cfg(not(target_os = "linux"))] pub fn rebuild(cx: &App) { if let Some(tray) = cx.try_global::() { @@ -196,7 +189,6 @@ pub fn set_enabled(cx: &mut App, enabled: bool) { linux::set_enabled(enabled, groups, connected); } -/// Builds the tray icon (unless disabled) and starts the gpui event-drain loop. pub fn init(cx: &mut App) { let enabled = !crate::backend::config::app_config_bool("disableTray"); @@ -258,32 +250,30 @@ fn handle_menu(id: &str, cx: &mut App) { "show" => actions::show_window(cx), "mode-rule" => actions::set_mode("rule", cx), "mode-global" => actions::set_mode("global", cx), - "toggle-proxy" => actions::toggle_tun(cx), + "toggle-proxy" => actions::toggle_connection(cx), "restart-core" => actions::restart_core(cx), - "quit-no-core" => actions::quit_without_core(cx), - "quit" => actions::quit_with_core(cx), + "quit" => actions::shutdown_and_quit(cx), _ => {} } } #[cfg(target_os = "linux")] mod linux { - use std::sync::mpsc::{channel, Receiver, Sender}; + use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::{Mutex, OnceLock}; use ksni::menu::{CheckmarkItem, StandardItem, SubMenu}; use ksni::{Handle, MenuItem, Tray, TrayMethods}; use rust_i18n::t; - use super::{load_icon_rgba, SEP}; + use super::{SEP, load_icon_rgba}; use crate::app::runtime; use crate::app::state::ProxyGroup; static HANDLE: Mutex>> = Mutex::new(None); - /// Persistent action channel: menu callbacks (on the ksni thread) push ids - /// the gpui loop drains via [`poll_action`]. Lives for the whole process so - /// it survives tray enable/disable cycles. + /// Menu callbacks run on the ksni thread and push ids the gpui loop drains. + /// Lives for the whole process, so it survives tray enable/disable cycles. fn actions() -> &'static (Sender, Mutex>) { static CH: OnceLock<(Sender, Mutex>)> = OnceLock::new(); CH.get_or_init(|| { @@ -405,7 +395,6 @@ mod linux { items.push(std_item("mode-global", t!("tray.modeGlobal").to_string())); items.push(MenuItem::Separator); items.push(std_item("restart-core", t!("tray.restartCore").to_string())); - items.push(std_item("quit-no-core", t!("tray.quitNoCore").to_string())); items.push(std_item("quit", t!("tray.quit").to_string())); items } @@ -449,12 +438,10 @@ mod linux { let present = HANDLE.lock().unwrap().is_some(); if enabled && !present { spawn_tray(groups, connected); - } else if !enabled { - if let Some(h) = HANDLE.lock().unwrap().take() { - runtime::detach(async move { - h.shutdown().await; - }); - } + } else if !enabled && let Some(h) = HANDLE.lock().unwrap().take() { + runtime::detach(async move { + h.shutdown().await; + }); } } } diff --git a/src/app/window.rs b/src/app/window.rs index a2d4cc1..4f2a103 100644 --- a/src/app/window.rs +++ b/src/app/window.rs @@ -1,5 +1,29 @@ use gpui::Window; +/// Asks for client-side decorations unless the user wants the system frame. +/// gpui's X11 backend defaults to server-side, which would stack the +/// compositor's title bar on ours; it downgrades the request when no +/// compositor is present, so `NyxApp::render` follows `window_decorations()`. +pub fn request_decorations(window: &Window, system_frame: bool) { + #[cfg(target_os = "linux")] + window.request_decorations(if system_frame { + gpui::WindowDecorations::Server + } else { + gpui::WindowDecorations::Client + }); + #[cfg(not(target_os = "linux"))] + { + let _ = (window, system_frame); + } +} + +pub fn apply_saved_decorations(window: &Window) { + request_decorations( + window, + crate::backend::config::app_config_bool("useWindowFrame"), + ); +} + #[cfg(windows)] use std::sync::atomic::{AtomicIsize, Ordering}; @@ -7,14 +31,13 @@ use std::sync::atomic::{AtomicIsize, Ordering}; #[cfg(windows)] static MAIN_HWND: AtomicIsize = AtomicIsize::new(0); -/// Records the main window's native handle for later show/hide. #[cfg(windows)] pub fn remember(window: &Window) { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - if let Ok(handle) = HasWindowHandle::window_handle(window) { - if let RawWindowHandle::Win32(w) = handle.as_raw() { - MAIN_HWND.store(w.hwnd.get(), Ordering::SeqCst); - } + if let Ok(handle) = HasWindowHandle::window_handle(window) + && let RawWindowHandle::Win32(w) = handle.as_raw() + { + MAIN_HWND.store(w.hwnd.get(), Ordering::SeqCst); } } @@ -32,7 +55,7 @@ fn hwnd() -> Option { /// Hides the window to the tray. Must run outside any gpui window borrow. #[cfg(windows)] pub fn hide_now() { - use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_HIDE}; + use windows::Win32::UI::WindowsAndMessaging::{SW_HIDE, ShowWindow}; if let Some(h) = hwnd() { unsafe { let _ = ShowWindow(h, SW_HIDE); @@ -44,7 +67,7 @@ pub fn hide_now() { #[cfg(windows)] pub fn show_now() { use windows::Win32::UI::WindowsAndMessaging::{ - IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE, SW_SHOW, + IsIconic, SW_RESTORE, SW_SHOW, SetForegroundWindow, ShowWindow, }; if let Some(h) = hwnd() { unsafe { @@ -59,8 +82,7 @@ pub fn show_now() { } } -/// Hides the window if it's visible and foreground, else shows it. Borrow -/// caveat as [`hide_now`]. +/// Hides if visible and focused, else shows. Borrow caveat as [`hide_now`]. #[cfg(windows)] pub fn toggle_now() { use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, IsWindowVisible}; @@ -81,7 +103,7 @@ pub fn toggle_now() { #[cfg(windows)] pub fn set_always_on_top(on: bool) { use windows::Win32::UI::WindowsAndMessaging::{ - SetWindowPos, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SetWindowPos, }; if let Some(h) = hwnd() { let after = if on { HWND_TOPMOST } else { HWND_NOTOPMOST }; @@ -102,7 +124,6 @@ pub fn set_always_on_top(on: bool) { #[cfg(not(windows))] pub fn set_always_on_top(_on: bool) {} -/// Non-Windows fallback: minimize stands in for tray-hide. #[cfg(not(windows))] pub fn hide(window: &Window) { window.minimize_window(); diff --git a/src/backend/api.rs b/src/backend/api.rs index dfd4284..74c449d 100644 --- a/src/backend/api.rs +++ b/src/backend/api.rs @@ -86,7 +86,7 @@ pub async fn reload_config(path: Option<&str>) -> Result<()> { } fn base_url() -> String { - crate::backend::manager::controller_url() + crate::backend::core::controller_url() } fn http() -> reqwest::Client { diff --git a/src/backend/config.rs b/src/backend/config.rs index b0e5a03..429508a 100644 --- a/src/backend/config.rs +++ b/src/backend/config.rs @@ -70,8 +70,7 @@ pub async fn patch_app_config(config: Value) -> Result<()> { write_json_as_yaml(&path, &base) } -/// Reads a top-level boolean flag from the app config synchronously (used at -/// startup before the async config load lands in state). +/// Sync app-config bool read, for startup before the async load lands in state. pub fn app_config_bool(key: &str) -> bool { let path = dirs::app_config_path(); std::fs::read_to_string(&path) @@ -81,7 +80,6 @@ pub fn app_config_bool(key: &str) -> bool { .unwrap_or(false) } -/// Reads a string key from the app config, falling back to `default`. pub fn app_config_str(key: &str, default: &str) -> String { let path = dirs::app_config_path(); std::fs::read_to_string(&path) @@ -91,8 +89,7 @@ pub fn app_config_str(key: &str, default: &str) -> String { .unwrap_or_else(|| default.to_string()) } -/// Reads the persisted main-window geometry `(x, y, width, height)` from the app -/// config, if present. Sync — called while opening the window. +/// Persisted main-window geometry `(x, y, w, h)`. Sync — used while opening the window. pub fn load_window_state() -> Option<(f64, f64, f64, f64)> { let path = dirs::app_config_path(); let v: Value = std::fs::read_to_string(&path) @@ -107,8 +104,7 @@ pub fn load_window_state() -> Option<(f64, f64, f64, f64)> { )) } -/// Persists the main-window geometry into the app config under `window`. Sync — -/// called from the window close/hide path (no gpui borrow held). +/// Persists the main-window geometry. Sync — called from the close/hide path. pub fn save_window_state(x: f64, y: f64, width: f64, height: f64) { let path = dirs::app_config_path(); let mut v: Value = std::fs::read_to_string(&path) @@ -149,8 +145,7 @@ pub async fn get_controled_mihomo_config() -> Result { read_yaml_as_json(&path) } -/// Persist a patch to the controlled overrides, mirror it into the live runtime -/// config, and PATCH it to the running core. +/// Persists a patch, mirrors it into the runtime config, and PATCHes the live core. pub async fn patch_controled_mihomo_config(config: Value) -> Result<()> { let overrides_path = dirs::controled_mihomo_config_path(); let mut base = if overrides_path.exists() { @@ -174,12 +169,14 @@ pub async fn patch_controled_mihomo_config(config: Value) -> Result<()> { write_json_as_yaml(&config_path, &running)?; } - let patch_url = format!("{}/configs", crate::backend::manager::controller_url()); - let _ = local_http().patch(&patch_url).json(&config).send().await; + let controller = crate::backend::core::controller_url(); + if crate::backend::core::is_active() && !controller.is_empty() { + let patch_url = format!("{controller}/configs"); + let _ = local_http().patch(&patch_url).json(&config).send().await; + } Ok(()) } -/// Nyx user-agent for subscription fetches. fn nyx_user_agent() -> String { format!("clash-meta/mihomo/Nyx-v{}", env!("CARGO_PKG_VERSION")) } @@ -254,12 +251,11 @@ pub async fn set_rule_str(id: String, str: String) -> Result<(), String> { } fn decode_header_value(value: &str) -> String { - if let Some(encoded) = value.strip_prefix("base64:") { - if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded.trim()) { - if let Ok(s) = String::from_utf8(bytes) { - return s; - } - } + if let Some(encoded) = value.strip_prefix("base64:") + && let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded.trim()) + && let Ok(s) = String::from_utf8(bytes) + { + return s; } value.to_string() } @@ -283,8 +279,7 @@ fn parse_subscription_userinfo(info: &str) -> Value { serde_json::json!({ "upload": upload, "download": download, "total": total, "expire": expire }) } -/// Imports or refreshes a profile item, updates `profile.yaml`, and hot-reloads -/// the core if the active profile changed. Returns the profile id. +/// Imports or refreshes a profile item and hot-reloads the core if it was active. pub async fn add_profile_item(item: Value) -> Result { let id = match item["id"].as_str().filter(|s| !s.is_empty()) { Some(existing_id) => existing_id.to_string(), @@ -316,17 +311,16 @@ pub async fn add_profile_item(item: Value) -> Result { .map_err(|e| e.to_string())?; let headers = resp.headers().clone(); - if let Some(v) = headers.get("subscription-userinfo") { - if let Ok(s) = v.to_str() { - meta["extra"] = parse_subscription_userinfo(s); - } + if let Some(v) = headers.get("subscription-userinfo") + && let Ok(s) = v.to_str() + { + meta["extra"] = parse_subscription_userinfo(s); } - if meta["name"].as_str().map(|s| s.is_empty()).unwrap_or(true) { - if let Some(v) = headers.get("profile-title") { - if let Ok(s) = v.to_str() { - meta["name"] = Value::String(decode_header_value(s)); - } - } + if meta["name"].as_str().map(|s| s.is_empty()).unwrap_or(true) + && let Some(v) = headers.get("profile-title") + && let Ok(s) = v.to_str() + { + meta["name"] = Value::String(decode_header_value(s)); } // A manually-set interval on the item takes precedence over the subscription's. let has_manual_interval = meta @@ -334,29 +328,27 @@ pub async fn add_profile_item(item: Value) -> Result { .and_then(Value::as_i64) .map(|v| v > 0) .unwrap_or(false); - if !has_manual_interval { - if let Some(v) = headers.get("profile-update-interval") { - if let Ok(s) = v.to_str() { - if let Ok(h) = s.trim().parse::() { - meta["interval"] = Value::Number((h * 60).into()); - } - } - } + if !has_manual_interval + && let Some(v) = headers.get("profile-update-interval") + && let Ok(s) = v.to_str() + && let Ok(h) = s.trim().parse::() + { + meta["interval"] = Value::Number((h * 60).into()); } - if let Some(v) = headers.get("profile-web-page-url") { - if let Ok(s) = v.to_str() { - meta["home"] = Value::String(s.to_string()); - } + if let Some(v) = headers.get("profile-web-page-url") + && let Ok(s) = v.to_str() + { + meta["home"] = Value::String(s.to_string()); } - if let Some(v) = headers.get("support-url") { - if let Ok(s) = v.to_str() { - meta["supportUrl"] = Value::String(s.to_string()); - } + if let Some(v) = headers.get("support-url") + && let Ok(s) = v.to_str() + { + meta["supportUrl"] = Value::String(s.to_string()); } - if let Some(v) = headers.get("announce") { - if let Ok(s) = v.to_str() { - meta["announce"] = Value::String(decode_header_value(s)); - } + if let Some(v) = headers.get("announce") + && let Ok(s) = v.to_str() + { + meta["announce"] = Value::String(decode_header_value(s)); } let raw_body = resp.text().await.map_err(|e| e.to_string())?; @@ -436,8 +428,7 @@ pub async fn add_profile_item(item: Value) -> Result { Ok(id) } -/// Rebuilds the merged runtime config and asks the running core to hot-reload -/// it. Best-effort: logs but does not fail on reload errors. +/// Rebuilds the merged runtime config and hot-reloads it. Best-effort. async fn reload_core() { if let Err(e) = crate::backend::manager::rebuild_config().await { log::warn!("[reload_core] rebuild_config failed: {e}"); @@ -452,7 +443,7 @@ async fn reload_core() { let path_str = config_path.to_string_lossy().replace('\\', "/"); let reload_url = format!( "{}/configs?force=false", - crate::backend::manager::controller_url() + crate::backend::core::controller_url() ); if let Err(e) = local_http() .put(&reload_url) @@ -487,8 +478,7 @@ pub async fn update_profile_item(item: Value) -> Result<(), String> { Err(format!("profile '{id}' not found")) } -/// Re-downloads every remote profile whose `interval` has elapsed. Returns the -/// names of the refreshed profiles. +/// Re-downloads every remote profile whose `interval` elapsed; returns their names. pub async fn run_due_auto_updates() -> Vec { let Ok(cfg) = profile_config().await else { return Vec::new(); @@ -578,17 +568,15 @@ async fn resolve_provider_path(path: &str) -> std::path::PathBuf { if Path::new(clean).is_absolute() { return PathBuf::from(clean); } - let config_dir = if let Ok(cm) = mihomo_config_manager() { - if let Ok(config_path) = cm.get_current_path().await { - config_path + let config_dir = match mihomo_config_manager() { + Ok(cm) => match cm.get_current_path().await { + Ok(config_path) => config_path .parent() .map(PathBuf::from) - .unwrap_or_else(dirs::data_dir) - } else { - dirs::data_dir() - } - } else { - dirs::data_dir() + .unwrap_or_else(dirs::data_dir), + _ => dirs::data_dir(), + }, + _ => dirs::data_dir(), }; config_dir.join(clean) } @@ -603,9 +591,7 @@ pub async fn set_file_str(path: String, str: String) -> Result<(), String> { fs::write(&full, str).map_err(|e| e.to_string()) } -/// Reads a rule/proxy provider's content for the Resources viewer, resolving the -/// on-disk path from the running config, decoding `.mrs` to text and surfacing -/// inline payloads. +/// Reads a provider's content for the viewer, decoding `.mrs` and inlining payloads. pub async fn read_provider_content(name: String, is_rule: bool) -> Result { use md5::{Digest, Md5}; @@ -629,15 +615,15 @@ pub async fn read_provider_content(name: String, is_rule: bool) -> Result Result Result<(), String> { - let _ = crate::backend::manager::stop_core().await; + let _ = crate::backend::core::stop().await; let data_dir = dirs::data_dir(); if data_dir.exists() { fs::remove_dir_all(&data_dir).map_err(|e| e.to_string())?; diff --git a/src/backend/core/mod.rs b/src/backend/core/mod.rs new file mode 100644 index 0000000..8baf776 --- /dev/null +++ b/src/backend/core/mod.rs @@ -0,0 +1,297 @@ +mod process; +mod spec; + +use std::time::Duration; + +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +pub use nyx_service::Status as ServiceStatus; + +use crate::backend::{api, config}; + +/// Generous — a large rule-provider set takes a while to load. +const READY_TIMEOUT: Duration = Duration::from_secs(20); +const READY_POLL: Duration = Duration::from_millis(200); +/// Liveness is far more expensive than an HTTP poll, so check it less often. +const LIVENESS_EVERY: u32 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureKind { + CoreMissing, + ConfigInvalid, + ServiceUnavailable, + Timeout, + Other, +} + +#[derive(Debug, Clone)] +pub struct CoreError { + pub kind: FailureKind, + pub detail: String, +} + +impl CoreError { + pub fn new(kind: FailureKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + } + } +} + +impl std::fmt::Display for CoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.detail) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Backend { + Service, + Direct, +} + +static CONTROLLER_URL: Lazy> = Lazy::new(|| Mutex::new(String::new())); +/// Which backend started the core that is running now, so `stop` targets it. +static ACTIVE: Lazy>> = Lazy::new(|| Mutex::new(None)); +/// Serialises start/stop/restart; the UI, the tray and the watchdog all call in. +static TRANSITION: Lazy> = Lazy::new(|| AsyncMutex::new(())); + +pub fn controller_url() -> String { + CONTROLLER_URL.lock().clone() +} + +pub fn set_controller_url(url: String) { + *CONTROLLER_URL.lock() = url; +} + +pub async fn service_status() -> ServiceStatus { + nyx_service::status().await.unwrap_or_else(|reason| { + log::warn!("[core] service status query failed: {reason}"); + ServiceStatus::Stale { reason } + }) +} + +pub fn service_managed() -> bool { + nyx_service::is_managed() +} + +pub async fn install_service() -> Result<(), CoreError> { + nyx_service::install() + .await + .map_err(|e| CoreError::new(FailureKind::ServiceUnavailable, e)) +} + +pub async fn uninstall_service() -> Result<(), CoreError> { + let _ = stop().await; + nyx_service::uninstall() + .await + .map_err(|e| CoreError::new(FailureKind::ServiceUnavailable, e)) +} + +pub async fn start_service() -> Result<(), CoreError> { + nyx_service::start_service() + .await + .map_err(|e| CoreError::new(FailureKind::ServiceUnavailable, e)) +} + +pub async fn stop_service() -> Result<(), CoreError> { + nyx_service::stop_service() + .await + .map_err(|e| CoreError::new(FailureKind::ServiceUnavailable, e))?; + let _ = stop().await; + Ok(()) +} + +pub async fn restart_service() -> Result<(), CoreError> { + nyx_service::restart_service() + .await + .map_err(|e| CoreError::new(FailureKind::ServiceUnavailable, e))?; + let _ = stop().await; + Ok(()) +} + +pub fn is_active() -> bool { + ACTIVE.lock().is_some() +} + +/// Brings the core up and does not return `Ok` until its controller answers. +pub async fn start() -> Result<(), CoreError> { + let _lock = TRANSITION.lock().await; + start_locked().await +} + +pub async fn stop() -> Result<(), CoreError> { + let _lock = TRANSITION.lock().await; + stop_locked().await; + Ok(()) +} + +pub async fn restart() -> Result<(), CoreError> { + let _lock = TRANSITION.lock().await; + stop_locked().await; + start_locked().await +} + +async fn start_locked() -> Result<(), CoreError> { + let backend = choose_backend().await?; + let spec = spec::build().await?; + log::info!( + "[core] starting via {backend:?}: binary={:?} config={:?} controller={}", + spec.binary, + spec.config, + spec.controller + ); + + let launched = match backend { + Backend::Service => { + let request = nyx_service::CoreSpec { + binary: spec.binary.clone(), + work_dir: spec.work_dir.clone(), + config: spec.config.clone(), + max_log_days: spec.max_log_days, + }; + nyx_service::start_core(&request) + .await + .map(|_| ()) + .map_err(classify_service_start) + } + Backend::Direct => process::spawn(&spec).await.map(|_| ()), + }; + if let Err(e) = launched { + return Err(explain(&spec, e).await); + } + *ACTIVE.lock() = Some(backend); + + set_controller_url(spec.controller.clone()); + api::init_client(&spec.controller, spec.secret.clone()) + .map_err(|e| CoreError::new(FailureKind::Other, e.to_string()))?; + + if let Err(e) = wait_ready(backend, &spec.controller).await { + return Err(explain(&spec, e).await); + } + log::info!("[core] ready at {}", spec.controller); + Ok(()) +} + +/// The host reports everything as one string; a core that came up and died is a +/// broken config, not a broken service. +fn classify_service_start(message: String) -> CoreError { + let kind = if message.contains("core exited") { + FailureKind::ConfigInvalid + } else if message.contains("core binary not found") { + FailureKind::CoreMissing + } else { + FailureKind::ServiceUnavailable + }; + CoreError::new(kind, message) +} + +/// A core that died or never answered is almost always a config the core +/// rejected, so name the offending key instead of the symptom. +async fn explain(spec: &spec::StartSpec, error: CoreError) -> CoreError { + if !matches!( + error.kind, + FailureKind::ConfigInvalid | FailureKind::Timeout + ) { + return error; + } + match spec::config_error(spec).await { + Some(detail) => CoreError::new(FailureKind::ConfigInvalid, detail), + None => error, + } +} + +async fn stop_locked() { + let active = ACTIVE.lock().take(); + match active { + Some(Backend::Service) => { + if let Err(e) = nyx_service::stop_core().await { + log::warn!("[core] service stop failed: {e}"); + } + } + Some(Backend::Direct) => process::stop().await, + // Unknown owner (fresh launch): clear both, so a previous run cannot keep + // the proxy alive behind us. + None => { + let _ = nyx_service::stop_core().await; + process::stop().await; + } + } +} + +async fn choose_backend() -> Result { + if config::app_config_str("corePermissionMode", "service") == "direct" { + return Ok(Backend::Direct); + } + match service_status().await { + ServiceStatus::NotInstalled => Err(CoreError::new( + FailureKind::ServiceUnavailable, + "the Nyx service is not installed", + )), + _ => Ok(Backend::Service), + } +} + +/// Polls the controller until it answers, giving up early if the core died. +async fn wait_ready(backend: Backend, url: &str) -> Result<(), CoreError> { + let deadline = tokio::time::Instant::now() + READY_TIMEOUT; + let mut tick = 0u32; + loop { + if api_responds(url).await { + return Ok(()); + } + tick += 1; + if tick.is_multiple_of(LIVENESS_EVERY) && !backend_alive(backend).await { + return Err(CoreError::new( + FailureKind::ConfigInvalid, + "the core exited right after starting — check the core log", + )); + } + if tokio::time::Instant::now() >= deadline { + return Err(CoreError::new( + FailureKind::Timeout, + format!("the core did not answer on {url} within 20s"), + )); + } + tokio::time::sleep(READY_POLL).await; + } +} + +async fn backend_alive(backend: Backend) -> bool { + match backend { + Backend::Service => matches!(nyx_service::ping().await, Ok(Some(_))), + Backend::Direct => process::alive(), + } +} + +pub async fn is_alive() -> bool { + let active = *ACTIVE.lock(); + match active { + Some(backend) => backend_alive(backend).await, + None => false, + } +} + +pub async fn is_ready() -> bool { + let url = controller_url(); + !url.is_empty() && api_responds(&url).await +} + +async fn api_responds(url: &str) -> bool { + let Ok(client) = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(2)) + .build() + else { + return false; + }; + client + .get(format!("{url}/version")) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) +} diff --git a/src/backend/core/process.rs b/src/backend/core/process.rs new file mode 100644 index 0000000..6321da9 --- /dev/null +++ b/src/backend/core/process.rs @@ -0,0 +1,175 @@ +use std::path::PathBuf; +use std::process::{Child, Stdio}; +use std::time::Duration; + +use once_cell::sync::Lazy; +use parking_lot::Mutex; + +use crate::backend::core::spec::StartSpec; +use crate::backend::core::{CoreError, FailureKind}; +use crate::backend::dirs; + +/// The core we spawned ourselves (unprivileged fallback backend). +static CHILD: Lazy>> = Lazy::new(|| Mutex::new(None)); + +const SETTLE: Duration = Duration::from_millis(400); +const STOP_GRACE: Duration = Duration::from_secs(3); + +fn pid_file() -> PathBuf { + dirs::data_dir().join("mihomo.pid") +} + +pub async fn spawn(spec: &StartSpec) -> Result { + stop().await; + + let mut cmd = std::process::Command::new(&spec.binary); + cmd.arg("-d") + .arg(&spec.work_dir) + .arg("-f") + .arg(&spec.config) + .stdin(Stdio::null()) + .stdout(core_log()) + .stderr(core_log()); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } + + let child = cmd.spawn().map_err(|e| { + CoreError::new( + FailureKind::CoreMissing, + format!("failed to run {}: {e}", spec.binary.display()), + ) + })?; + let pid = child.id(); + *CHILD.lock() = Some(child); + write_pid(pid).await; + + tokio::time::sleep(SETTLE).await; + if let Some(status) = exit_status() { + CHILD.lock().take(); + return Err(CoreError::new( + FailureKind::ConfigInvalid, + format!( + "the core exited immediately ({status}); see {}", + core_log_path().display() + ), + )); + } + + Ok(pid) +} + +pub fn alive() -> bool { + let mut guard = CHILD.lock(); + match guard.as_mut() { + Some(child) => match child.try_wait() { + Ok(None) => true, + _ => { + guard.take(); + false + } + }, + None => false, + } +} + +fn exit_status() -> Option { + CHILD + .lock() + .as_mut() + .and_then(|child| child.try_wait().ok().flatten()) +} + +pub async fn stop() { + // Bind first: an `if let` scrutinee would hold the guard across the awaits. + let owned = CHILD.lock().take(); + if let Some(mut child) = owned { + terminate(child.id()); + wait_for_exit(&mut child).await; + } else if let Some(pid) = read_pid().await { + // A core left behind by a previous run of the app. + if owned_by_us(pid) { + terminate(pid); + } + } + let _ = tokio::fs::remove_file(pid_file()).await; +} + +async fn wait_for_exit(child: &mut Child) { + let deadline = tokio::time::Instant::now() + STOP_GRACE; + loop { + match child.try_wait() { + Ok(Some(_)) | Err(_) => return, + Ok(None) if tokio::time::Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return; + } + Ok(None) => tokio::time::sleep(Duration::from_millis(50)).await, + } + } +} + +#[cfg(windows)] +fn terminate(pid: u32) { + use std::os::windows::process::CommandExt; + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(0x08000000) + .output(); +} + +#[cfg(not(windows))] +fn terminate(pid: u32) { + unsafe { libc::kill(pid as i32, libc::SIGTERM) }; +} + +/// Guards against killing an unrelated process that reused the recorded pid. +#[cfg(target_os = "linux")] +fn owned_by_us(pid: u32) -> bool { + std::fs::read_to_string(format!("/proc/{pid}/comm")) + .map(|comm| comm.trim().contains("mihomo")) + .unwrap_or(false) +} + +#[cfg(not(target_os = "linux"))] +fn owned_by_us(_pid: u32) -> bool { + true +} + +async fn write_pid(pid: u32) { + let path = pid_file(); + if let Some(parent) = path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let _ = tokio::fs::write(&path, pid.to_string()).await; +} + +async fn read_pid() -> Option { + tokio::fs::read_to_string(pid_file()) + .await + .ok()? + .trim() + .parse() + .ok() +} + +fn core_log_path() -> PathBuf { + dirs::log_dir().join("mihomo.log") +} + +fn core_log() -> Stdio { + let path = core_log_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::File::create(&path) + .map(Stdio::from) + .unwrap_or_else(|_| Stdio::null()) +} diff --git a/src/backend/core/spec.rs b/src/backend/core/spec.rs new file mode 100644 index 0000000..bb50e78 --- /dev/null +++ b/src/backend/core/spec.rs @@ -0,0 +1,151 @@ +use std::path::PathBuf; + +use crate::backend::core::{CoreError, FailureKind}; +use crate::backend::{dirs, manager}; + +/// Resolved and validated up front, so a failure names the actual cause. +#[derive(Debug, Clone)] +pub struct StartSpec { + pub binary: PathBuf, + pub work_dir: PathBuf, + pub config: PathBuf, + pub controller: String, + pub secret: Option, + pub max_log_days: u32, +} + +pub async fn build() -> Result { + let app_cfg = read_app_config().await; + let binary = resolve_binary(&app_cfg).await?; + + let controller = manager::rebuild_config() + .await + .map_err(|e| CoreError::new(FailureKind::ConfigInvalid, e.to_string()))?; + + let config = mihomo_rs::ConfigManager::with_home(dirs::data_dir()) + .map_err(|e| CoreError::new(FailureKind::Other, e.to_string()))? + .get_current_path() + .await + .map_err(|e| CoreError::new(FailureKind::Other, e.to_string()))?; + + let work_dir = config + .parent() + .map(PathBuf::from) + .ok_or_else(|| CoreError::new(FailureKind::Other, "config has no parent directory"))?; + + Ok(StartSpec { + secret: read_secret(&config).await, + max_log_days: app_cfg + .get("maxLogDays") + .and_then(|v| v.as_u64()) + .unwrap_or(7) as u32, + binary, + work_dir, + config, + controller, + }) +} + +async fn read_app_config() -> serde_yaml::Value { + tokio::fs::read_to_string(dirs::app_config_path()) + .await + .ok() + .and_then(|s| serde_yaml::from_str(&s).ok()) + .unwrap_or(serde_yaml::Value::Null) +} + +async fn resolve_binary(app_cfg: &serde_yaml::Value) -> Result { + let core = app_cfg + .get("core") + .and_then(|v| v.as_str()) + .unwrap_or("mihomo"); + + if core == "system" { + let path = app_cfg + .get("systemCorePath") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + CoreError::new( + FailureKind::CoreMissing, + "no system core path is configured", + ) + })?; + let path = PathBuf::from(path); + if !path.exists() { + return Err(CoreError::new( + FailureKind::CoreMissing, + format!("{} does not exist", path.display()), + )); + } + return Ok(path); + } + + manager::ensure_core_installed(core) + .await + .map_err(|e| CoreError::new(FailureKind::CoreMissing, e.to_string()))?; + + mihomo_rs::VersionManager::with_home(dirs::data_dir()) + .map_err(|e| CoreError::new(FailureKind::CoreMissing, e.to_string()))? + .get_binary_path(None) + .await + .map_err(|e| CoreError::new(FailureKind::CoreMissing, e.to_string())) +} + +pub async fn config_error(spec: &StartSpec) -> Option { + let mut cmd = tokio::process::Command::new(&spec.binary); + cmd.arg("-t") + .arg("-d") + .arg(&spec.work_dir) + .arg("-f") + .arg(&spec.config); + #[cfg(windows)] + cmd.creation_flags(0x08000000); + + let out = cmd.output().await.ok()?; + if out.status.success() { + return None; + } + + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + // A core build without `-t` must not be treated as a broken profile. + if text.contains("flag provided but not defined") { + log::warn!("[core] this core does not support -t, skipping config validation"); + return None; + } + Some(summarize(&text)) +} + +/// mihomo prints a banner first; the tail holds the offending key. +fn summarize(text: &str) -> String { + let lines: Vec<&str> = text + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); + let tail = lines + .iter() + .rev() + .take(3) + .rev() + .copied() + .collect::>(); + if tail.is_empty() { + "the configuration was rejected by the core".to_string() + } else { + tail.join("; ") + } +} + +async fn read_secret(config: &std::path::Path) -> Option { + let content = tokio::fs::read_to_string(config).await.ok()?; + let val: serde_yaml::Value = serde_yaml::from_str(&content).ok()?; + val.get("secret") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} diff --git a/src/backend/elevation.rs b/src/backend/elevation.rs deleted file mode 100644 index fddd19d..0000000 --- a/src/backend/elevation.rs +++ /dev/null @@ -1,111 +0,0 @@ -#[cfg(target_os = "windows")] -pub fn is_elevated() -> bool { - use std::mem; - use std::ptr; - unsafe { - let mut token: *mut std::ffi::c_void = ptr::null_mut(); - #[link(name = "advapi32")] - extern "system" { - fn OpenProcessToken( - process: *mut std::ffi::c_void, - desired_access: u32, - token_handle: *mut *mut std::ffi::c_void, - ) -> i32; - fn GetTokenInformation( - token_handle: *mut std::ffi::c_void, - token_information_class: u32, - token_information: *mut std::ffi::c_void, - token_information_length: u32, - return_length: *mut u32, - ) -> i32; - } - #[link(name = "kernel32")] - extern "system" { - fn GetCurrentProcess() -> *mut std::ffi::c_void; - fn CloseHandle(handle: *mut std::ffi::c_void) -> i32; - } - - const TOKEN_QUERY: u32 = 0x0008; - const TOKEN_ELEVATION: u32 = 20; - - if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { - return false; - } - - #[repr(C)] - struct TokenElevation { - token_is_elevated: u32, - } - let mut elevation: TokenElevation = mem::zeroed(); - let mut size: u32 = 0; - let ok = GetTokenInformation( - token, - TOKEN_ELEVATION, - &mut elevation as *mut _ as *mut std::ffi::c_void, - mem::size_of::() as u32, - &mut size, - ); - CloseHandle(token); - ok != 0 && elevation.token_is_elevated != 0 - } -} - -#[cfg(not(target_os = "windows"))] -pub fn is_elevated() -> bool { - unsafe { libc::geteuid() == 0 } -} - -/// Net capabilities the mihomo core needs to create a TUN device. -#[cfg(target_os = "linux")] -const TUN_CAPS: [caps::Capability; 3] = [ - caps::Capability::CAP_NET_ADMIN, - caps::Capability::CAP_NET_BIND_SERVICE, - caps::Capability::CAP_NET_RAW, -]; - -/// Raises the TUN net caps into the ambient set so the spawned core inherits -/// them. No-op unless this process already holds them (via `setcap`). -#[cfg(target_os = "linux")] -pub fn raise_net_ambient_caps() { - use caps::CapSet; - for cap in TUN_CAPS { - if caps::raise(None, CapSet::Inheritable, cap).is_err() { - continue; - } - let _ = caps::raise(None, CapSet::Ambient, cap); - } -} - -#[cfg(target_os = "linux")] -pub fn is_nixos() -> bool { - std::path::Path::new("/etc/NIXOS").exists() -} - -/// Whether this process already holds `CAP_NET_ADMIN` (so the core can TUN). -#[cfg(target_os = "linux")] -pub fn has_net_admin() -> bool { - caps::has_cap( - None, - caps::CapSet::Permitted, - caps::Capability::CAP_NET_ADMIN, - ) - .unwrap_or(false) -} - -/// Grants the TUN net caps to the Nyx executable via `pkexec setcap`. Takes -/// effect on the next launch (a running process can't gain file caps live). -#[cfg(target_os = "linux")] -pub fn grant_tun_caps() -> Result<(), String> { - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let status = std::process::Command::new("pkexec") - .arg("setcap") - .arg("cap_net_admin,cap_net_bind_service,cap_net_raw=+ep") - .arg(&exe) - .status() - .map_err(|e| format!("failed to run pkexec/setcap: {e}"))?; - if status.success() { - Ok(()) - } else { - Err("granting TUN privileges was cancelled or failed".to_string()) - } -} diff --git a/src/backend/manager.rs b/src/backend/manager.rs index d79b71b..34bff3f 100644 --- a/src/backend/manager.rs +++ b/src/backend/manager.rs @@ -1,16 +1,10 @@ use anyhow::Result; use mihomo_rs::{Channel, ConfigManager, VersionManager}; use once_cell::sync::Lazy; -use parking_lot::Mutex; -use std::path::PathBuf; use tokio::sync::Mutex as AsyncMutex; -static CONTROLLER_URL: Lazy> = Lazy::new(|| Mutex::new(String::new())); - static REBUILD_LOCK: Lazy> = Lazy::new(|| AsyncMutex::new(())); -static CORE_CHILD: Lazy>> = Lazy::new(|| Mutex::new(None)); - fn vm() -> Result { VersionManager::with_home(crate::backend::dirs::data_dir()).map_err(|e| anyhow::anyhow!("{e}")) } @@ -25,146 +19,7 @@ fn version_matches_channel(version: &str, want_alpha: bool) -> bool { || lower.contains("preview") || lower.contains("pre") || lower.contains("nightly"); - if want_alpha { - is_alpha - } else { - !is_alpha - } -} - -fn pid_file() -> PathBuf { - crate::backend::dirs::data_dir().join("mihomo.pid") -} - -async fn spawn_mihomo(binary: &std::path::Path, config: &std::path::Path) -> Result { - let mut cmd = std::process::Command::new(binary); - cmd.arg("-d") - .arg( - config - .parent() - .ok_or_else(|| anyhow::anyhow!("config has no parent dir"))?, - ) - .arg("-f") - .arg(config) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x08000000); - } - - // Let the core inherit CAP_NET_ADMIN for TUN; no-op unless Nyx holds the caps. - #[cfg(target_os = "linux")] - crate::backend::elevation::raise_net_ambient_caps(); - - let child = cmd - .spawn() - .map_err(|e| anyhow::anyhow!("failed to spawn mihomo: {e}"))?; - let pid = child.id(); - *CORE_CHILD.lock() = Some(child); - - let pf = pid_file(); - if let Some(p) = pf.parent() { - let _ = std::fs::create_dir_all(p); - } - tokio::fs::write(&pf, pid.to_string()) - .await - .map_err(|e| anyhow::anyhow!("failed to write PID file: {e}"))?; - - Ok(pid) -} - -/// `Some` once the core we spawned has exited — a config or binary problem -/// usually kills it within a second, long before the readiness timeout. -fn core_exit_status() -> Option { - CORE_CHILD - .lock() - .as_mut() - .and_then(|child| child.try_wait().ok().flatten()) -} - -/// Waits briefly for the killed core to be reaped, then drops the handle. -async fn reap_core_child() { - for _ in 0..20 { - let exited = match CORE_CHILD.lock().as_mut() { - Some(child) => !matches!(child.try_wait(), Ok(None)), - None => true, - }; - if exited { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - CORE_CHILD.lock().take(); -} - -async fn stop_mihomo() -> Result<()> { - let pf = pid_file(); - if !pf.exists() { - reap_core_child().await; - return Ok(()); - } - let content = match tokio::fs::read_to_string(&pf).await { - Ok(s) => s, - Err(_) => return Ok(()), - }; - let pid: u32 = match content.trim().parse() { - Ok(p) => p, - Err(_) => { - let _ = tokio::fs::remove_file(&pf).await; - return Ok(()); - } - }; - - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - let _ = std::process::Command::new("taskkill") - .args(["/PID", &pid.to_string(), "/F"]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .creation_flags(0x08000000) - .output(); - } - - #[cfg(not(target_os = "windows"))] - { - #[cfg(target_os = "linux")] - let is_core = match std::fs::read_to_string(format!("/proc/{pid}/comm")) { - Ok(comm) => { - let comm = comm.trim(); - comm.contains("mihomo") || comm == system_core_name().await.unwrap_or_default() - } - Err(_) => false, - }; - #[cfg(not(target_os = "linux"))] - let is_core = true; - if is_core { - let _ = std::process::Command::new("kill") - .args(["-TERM", &pid.to_string()]) - .output(); - } - } - - reap_core_child().await; - let _ = tokio::fs::remove_file(&pf).await; - Ok(()) -} - -#[cfg(target_os = "linux")] -async fn system_core_name() -> Option { - let cfg = tokio::fs::read_to_string(crate::backend::dirs::app_config_path()) - .await - .ok()?; - let val: serde_yaml::Value = serde_yaml::from_str(&cfg).ok()?; - let path = val.get("systemCorePath")?.as_str()?; - std::path::Path::new(path) - .file_name() - .map(|n| n.to_string_lossy().into_owned()) + if want_alpha { is_alpha } else { !is_alpha } } async fn read_current_profile_id() -> Option { @@ -273,19 +128,19 @@ async fn read_mihomo_overrides() -> String { if content.is_empty() { return content; } - if let Ok(mut val) = serde_yaml::from_str::(&content) { - if let serde_yaml::Value::Mapping(ref mut map) = val { - let before = map.len(); - map.retain(|_, v| !v.is_null()); - if map.len() != before { - log::info!( - "[read_mihomo_overrides] cleaned {} stale null entries from mihomo.yaml", - before - map.len() - ); - let clean = serde_yaml::to_string(&val).unwrap_or_default(); - let _ = tokio::fs::write(&path, &clean).await; - return clean; - } + if let Ok(mut val) = serde_yaml::from_str::(&content) + && let serde_yaml::Value::Mapping(ref mut map) = val + { + let before = map.len(); + map.retain(|_, v| !v.is_null()); + if map.len() != before { + log::info!( + "[read_mihomo_overrides] cleaned {} stale null entries from mihomo.yaml", + before - map.len() + ); + let clean = serde_yaml::to_string(&val).unwrap_or_default(); + let _ = tokio::fs::write(&path, &clean).await; + return clean; } } content @@ -298,18 +153,17 @@ fn merge_yaml(base: &str, patch: &str) -> String { serde_yaml::from_str(base).unwrap_or(serde_yaml::Value::Mapping(Default::default())) }; - if !patch.is_empty() { - if let Ok(patch_val) = serde_yaml::from_str::(patch) { - deep_merge_yaml(&mut base_val, patch_val); - } + if !patch.is_empty() + && let Ok(patch_val) = serde_yaml::from_str::(patch) + { + deep_merge_yaml(&mut base_val, patch_val); } serde_yaml::to_string(&base_val).unwrap_or_default() } -/// Layers the app's override block onto a section (`dns`/`sniffer`/`tun`). With -/// `force`, the app block wins; otherwise a non-empty profile section is kept. -/// `tun` always retains its `enable` key (the master switch). +/// Layers the app's override onto a section. With `force` the app wins; else a +/// non-empty profile section is kept. `tun` always keeps its `enable` key. fn apply_section_policy( profile: &serde_yaml::Value, overrides: &mut serde_yaml::Value, @@ -327,7 +181,7 @@ fn apply_section_policy( if !profile_has { return; } - let serde_yaml::Value::Mapping(ref mut map) = overrides else { + let serde_yaml::Value::Mapping(map) = overrides else { return; }; let key = serde_yaml::Value::String(section.to_string()); @@ -343,30 +197,40 @@ fn apply_section_policy( } fn deep_merge_yaml(base: &mut serde_yaml::Value, patch: serde_yaml::Value) { - if let (serde_yaml::Value::Mapping(ref mut base_map), serde_yaml::Value::Mapping(patch_map)) = + if let (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(patch_map)) = (base, patch) { for (k, v) in patch_map { if v.is_null() { continue; } - if v.is_mapping() { - if let Some(existing) = base_map.get_mut(&k) { - if existing.is_mapping() { - deep_merge_yaml(existing, v); - continue; - } - } + if v.is_mapping() + && let Some(existing) = base_map.get_mut(&k) + && existing.is_mapping() + { + deep_merge_yaml(existing, v); + continue; } base_map.insert(k, v); } } } -fn ensure_external_controller_in_yaml( - yaml: &str, - preferred_addr: Option<&str>, -) -> (String, String) { +/// Fixed, so the app and the core agree across restarts without persisting it. +const DEFAULT_CONTROLLER_PORT: u16 = 9097; + +fn free_controller_port() -> u16 { + let taken = |p: u16| std::net::TcpListener::bind(("127.0.0.1", p)).is_err(); + if !taken(DEFAULT_CONTROLLER_PORT) { + return DEFAULT_CONTROLLER_PORT; + } + log::warn!("[core] port {DEFAULT_CONTROLLER_PORT} is busy, picking another"); + (DEFAULT_CONTROLLER_PORT + 1..DEFAULT_CONTROLLER_PORT + 100) + .find(|p| !taken(*p)) + .unwrap_or(DEFAULT_CONTROLLER_PORT) +} + +fn ensure_external_controller_in_yaml(yaml: &str) -> (String, String) { let mut val: serde_yaml::Value = if yaml.is_empty() { serde_yaml::Value::Mapping(Default::default()) } else { @@ -382,16 +246,7 @@ fn ensure_external_controller_in_yaml( let addr = match existing_addr { Some(a) => a, None => { - let a = if let Some(p) = preferred_addr.filter(|s| !s.is_empty()) { - p.trim_start_matches("http://") - .trim_start_matches("https://") - .to_string() - } else { - let port = (9090u16..9190) - .find(|p| std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok()) - .unwrap_or(9090); - format!("127.0.0.1:{port}") - }; + let a = format!("127.0.0.1:{}", free_controller_port()); if let serde_yaml::Value::Mapping(ref mut map) = val { map.insert( serde_yaml::Value::String("external-controller".into()), @@ -460,14 +315,7 @@ pub async fn rebuild_config() -> Result { base_merged }; - let running_url = CONTROLLER_URL.lock().clone(); - let preferred_addr = if running_url.is_empty() { - None - } else { - Some(running_url.as_str()) - }; - - let (final_yaml, url) = ensure_external_controller_in_yaml(&merged, preferred_addr); + let (final_yaml, url) = ensure_external_controller_in_yaml(&merged); if let Ok(val) = serde_yaml::from_str::(&final_yaml) { let tun_enable = val.get("tun").and_then(|t| t.get("enable")); @@ -573,145 +421,6 @@ pub async fn install_core_for_core_type(core: &str) -> Result<()> { Ok(()) } -pub async fn start_core() -> Result { - log::info!("[start_core] stopping any existing core..."); - stop_core().await.ok(); - - let app_cfg = tokio::fs::read_to_string(crate::backend::dirs::app_config_path()) - .await - .ok() - .and_then(|s| serde_yaml::from_str::(&s).ok()) - .unwrap_or_default(); - let core_type = app_cfg - .get("core") - .and_then(|v| v.as_str()) - .unwrap_or("mihomo"); - - let binary = if core_type == "system" { - let system_path = app_cfg - .get("systemCorePath") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| anyhow::anyhow!("system core path is not configured"))?; - let p = std::path::PathBuf::from(system_path); - if !p.exists() { - return Err(anyhow::anyhow!( - "system core does not exist: {}", - p.display() - )); - } - p - } else { - ensure_core_installed(core_type).await?; - let vm = vm()?; - vm.get_binary_path(None) - .await - .map_err(|e| anyhow::anyhow!("{e}"))? - }; - log::info!("[start_core] binary: {:?}", binary); - - let url = rebuild_config().await?; - log::info!("[start_core] rebuild_config returned url={url}"); - - save_controller_to_overrides(&url).await; - - let cm = cm()?; - let config = cm - .get_current_path() - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - log::info!("[start_core] config path: {:?}", config); - - let secret = extract_secret_from_config(&config).await; - if secret.is_some() { - log::info!("[start_core] extracted secret from config (non-empty)"); - } - - spawn_mihomo(&binary, &config).await?; - log::info!("[start_core] mihomo process started"); - - *CONTROLLER_URL.lock() = url.clone(); - crate::backend::api::init_client(&url, secret)?; - log::info!("[start_core] API client initialised"); - - if !wait_for_core_ready(&url).await { - return Err(anyhow::anyhow!( - "mihomo was spawned but its API never came up — the binary or profile config is likely broken" - )); - } - - log::info!("[start_core] mihomo ready at {url}"); - Ok(url) -} - -async fn extract_secret_from_config(config_path: &std::path::Path) -> Option { - let content = tokio::fs::read_to_string(config_path).await.ok()?; - let val: serde_yaml::Value = serde_yaml::from_str(&content).ok()?; - val.get("secret") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) -} - -async fn save_controller_to_overrides(url: &str) { - use crate::backend::dirs; - let addr = url - .trim_start_matches("http://") - .trim_start_matches("https://") - .to_string(); - let path = dirs::controled_mihomo_config_path(); - let mut val: serde_yaml::Value = if path.exists() { - tokio::fs::read_to_string(&path) - .await - .ok() - .and_then(|s| serde_yaml::from_str(&s).ok()) - .unwrap_or(serde_yaml::Value::Mapping(Default::default())) - } else { - serde_yaml::Value::Mapping(Default::default()) - }; - if let serde_yaml::Value::Mapping(ref mut map) = val { - map.insert( - serde_yaml::Value::String("external-controller".into()), - serde_yaml::Value::String(addr), - ); - } - let _ = tokio::fs::write(&path, serde_yaml::to_string(&val).unwrap_or_default()).await; -} - -pub async fn stop_core() -> Result<()> { - #[cfg(windows)] - if crate::backend::service::service_status().await == Ok("running".to_string()) { - log::info!("[stop_core] mihomo relies on background service, skipping local stop"); - return Ok(()); - } - - log::info!("[stop_core] stopping mihomo..."); - match stop_mihomo().await { - Ok(_) => log::info!("[stop_core] mihomo stopped"), - Err(e) => log::warn!("[stop_core] stop error (may be expected): {e}"), - } - Ok(()) -} - -pub async fn restart_core() -> Result { - log::info!("[restart_core] restarting..."); - stop_core().await.ok(); - let result = start_core().await; - match &result { - Ok(url) => log::info!("[restart_core] restarted successfully at {url}"), - Err(e) => log::error!("[restart_core] failed: {e}"), - } - result -} - -pub fn controller_url() -> String { - CONTROLLER_URL.lock().clone() -} - -pub fn set_controller_url(url: String) { - *CONTROLLER_URL.lock() = url; -} - pub async fn core_installed() -> bool { match vm() { Ok(vm) => vm.get_binary_path(None).await.is_ok(), @@ -731,24 +440,3 @@ pub async fn get_installed_version() -> Result { .map(|v| v.version) .ok_or_else(|| anyhow::anyhow!("no installed versions")) } - -async fn wait_for_core_ready(url: &str) -> bool { - let version_url = format!("{url}/version"); - let client = reqwest::Client::builder() - .no_proxy() - .timeout(std::time::Duration::from_secs(2)) - .build() - .unwrap_or_default(); - for _ in 0..50 { - if client.get(&version_url).send().await.is_ok() { - return true; - } - if let Some(status) = core_exit_status() { - log::warn!("mihomo exited right after start ({status})"); - return false; - } - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - log::warn!("mihomo did not become ready within 10 seconds"); - false -} diff --git a/src/backend/mihomo.rs b/src/backend/mihomo.rs index 2259718..a61eb4d 100644 --- a/src/backend/mihomo.rs +++ b/src/backend/mihomo.rs @@ -1,9 +1,8 @@ use serde_json::Value; -use crate::backend::{api, config, dirs, manager}; +use crate::backend::{api, config, core, dirs}; -/// Returns proxy groups as JSON objects, each with its `all` members resolved to -/// full proxy objects. +/// Proxy groups with every `all` member resolved to its full proxy object. pub async fn groups() -> anyhow::Result { let proxies = api::get_raw_proxies_map().await?; @@ -57,7 +56,7 @@ fn local_http() -> reqwest::Client { pub async fn change_proxy(group: &str, proxy: &str) -> anyhow::Result<()> { let encoded_group = group.replace(' ', "%20"); - let url = format!("{}/proxies/{}", manager::controller_url(), encoded_group); + let url = format!("{}/proxies/{}", core::controller_url(), encoded_group); local_http() .put(&url) .json(&serde_json::json!({ "name": proxy })) @@ -94,13 +93,12 @@ pub async fn restore_proxy_selections() { let Ok(map) = serde_yaml::from_str::(&text) else { return; }; - let base_url = manager::controller_url(); + let base_url = core::controller_url(); if base_url.is_empty() { return; } - // Selections are stored globally across profiles, so validate each against - // the live proxy set first — a stale group/proxy would 404 if restored blindly. + // Selections are global across profiles, so a stale group/proxy would 404. let proxies = match api::get_raw_proxies_map().await { Ok(p) => p, Err(e) => { diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 0a15beb..8d5325f 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -1,21 +1,11 @@ pub mod api; pub mod config; +pub mod core; pub mod dirs; -pub mod elevation; pub mod manager; pub mod mihomo; pub mod proxy_convert; -pub mod service; -pub mod service_host; pub mod startup; pub mod streaming; pub mod sysproxy; pub mod updater; - -#[cfg(windows)] -pub use service_host::maybe_run_as_service_from_args; - -#[cfg(not(windows))] -pub fn maybe_run_as_service_from_args() -> Option { - None -} diff --git a/src/backend/proxy_convert.rs b/src/backend/proxy_convert.rs index b901e0f..0a178fd 100644 --- a/src/backend/proxy_convert.rs +++ b/src/backend/proxy_convert.rs @@ -170,12 +170,13 @@ fn percent_decode(s: &str) -> String { let src = s.as_bytes(); let mut i = 0; while i < src.len() { - if src[i] == b'%' && i + 2 < src.len() { - if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { - buf.push(b); - i += 3; - continue; - } + if src[i] == b'%' + && i + 2 < src.len() + && let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) + { + buf.push(b); + i += 3; + continue; } buf.push(src[i]); i += 1; @@ -405,7 +406,6 @@ fn parse_ss(uri: &str) -> Option { None => (rest, String::new()), }; - // Strip plugin params let (main, _plugin) = match before_hash.find('?') { Some(i) => (&before_hash[..i], &before_hash[i + 1..]), None => (before_hash, ""), @@ -421,7 +421,6 @@ fn parse_ss(uri: &str) -> Option { percent_decode(&userinfo[c + 1..]), ) } else { - // base64-encoded userinfo let decoded = base64_decode_any(userinfo)?; let c = decoded.find(':')?; (decoded[..c].to_string(), decoded[c + 1..].to_string()) diff --git a/src/backend/service.rs b/src/backend/service.rs deleted file mode 100644 index 6d3cd44..0000000 --- a/src/backend/service.rs +++ /dev/null @@ -1,613 +0,0 @@ -#![allow(dead_code)] - -#[cfg(windows)] -use crate::backend::elevation; -use crate::backend::{api, dirs, manager}; - -const SERVICE_NAME: &str = "Nyx Service"; -const SERVICE_DISPLAY_NAME: &str = "Nyx Mihomo Service"; - -#[cfg(windows)] -fn run_sc(args: &[String]) -> Result { - use std::os::windows::process::CommandExt; - std::process::Command::new("sc") - .args(args) - .creation_flags(0x08000000) - .output() - .map_err(|e| e.to_string()) -} - -#[cfg(windows)] -fn output_message(out: &std::process::Output) -> String { - let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !stderr.is_empty() { - stderr - } else { - stdout - } -} - -#[cfg(windows)] -fn service_query_state() -> Result, String> { - use windows_service::service::{ServiceAccess, ServiceState}; - use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; - - let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) - .map_err(|e| format!("failed to open service manager: {e}"))?; - - let service = match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) { - Ok(s) => s, - Err(windows_service::Error::Winapi(ref e)) if e.raw_os_error() == Some(1060) => { - return Ok(None); - } - Err(e) => return Err(format!("failed to open service: {e}")), - }; - - let status = service - .query_status() - .map_err(|e| format!("failed to query service status: {e}"))?; - - Ok(Some(match status.current_state { - ServiceState::Running | ServiceState::StartPending | ServiceState::ContinuePending => { - "running".to_string() - } - ServiceState::Stopped | ServiceState::StopPending => "stopped".to_string(), - ServiceState::Paused | ServiceState::PausePending => "stopped".to_string(), - })) -} - -#[cfg(windows)] -async fn ensure_core_binary() -> Result { - let mut selected_core = "mihomo".to_string(); - let app_cfg_path = dirs::app_config_path(); - if let Ok(cfg_text) = tokio::fs::read_to_string(&app_cfg_path).await { - if let Ok(cfg) = serde_yaml::from_str::(&cfg_text) { - let core = cfg.get("core").and_then(|v| v.as_str()).unwrap_or("mihomo"); - selected_core = core.to_string(); - if core == "system" { - let path = cfg - .get("systemCorePath") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| "system core path is not configured".to_string())?; - let p = std::path::PathBuf::from(path); - if !p.exists() { - return Err(format!("system core does not exist: {}", p.display())); - } - return Ok(p); - } - } - } - - manager::ensure_core_installed(&selected_core) - .await - .map_err(|e| e.to_string())?; - - let vm = mihomo_rs::VersionManager::with_home(dirs::data_dir()).map_err(|e| e.to_string())?; - vm.get_binary_path(None).await.map_err(|e| e.to_string()) -} - -#[cfg(windows)] -async fn ensure_runtime_config() -> Result<(std::path::PathBuf, String), String> { - let url = manager::rebuild_config().await.map_err(|e| e.to_string())?; - let cm = mihomo_rs::ConfigManager::with_home(dirs::data_dir()).map_err(|e| e.to_string())?; - let config = cm.get_current_path().await.map_err(|e| e.to_string())?; - Ok((config, url)) -} - -#[cfg(windows)] -fn build_service_binpath() -> Result { - let service_host_exe = std::env::current_exe().map_err(|e| e.to_string())?; - Ok(format!("\"{}\" --nyx-service", service_host_exe.display())) -} - -#[cfg(windows)] -fn read_secret_from_config(config: &std::path::Path) -> Option { - let content = std::fs::read_to_string(config).ok()?; - let val: serde_yaml::Value = serde_yaml::from_str(&content).ok()?; - val.get("secret") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) -} - -#[cfg(windows)] -fn read_max_log_days() -> u32 { - let path = dirs::app_config_path(); - std::fs::read_to_string(&path) - .ok() - .and_then(|s| serde_yaml::from_str::(&s).ok()) - .and_then(|v| v.get("maxLogDays").and_then(|x| x.as_u64())) - .map(|d| d as u32) - .unwrap_or(7) -} - -#[cfg(not(windows))] -fn read_secret_from_config(_config: &std::path::Path) -> Option { - None -} - -#[cfg(windows)] -fn sync_controller(url: &str, config: &std::path::Path) -> Result<(), String> { - manager::set_controller_url(url.to_string()); - api::init_client(url, read_secret_from_config(config)).map_err(|e| e.to_string()) -} - -#[cfg(windows)] -fn ensure_service_installed_args() -> Result<(), String> { - let bin_path = build_service_binpath()?; - let action = if service_query_state()?.is_some() { - "config" - } else { - "create" - }; - let out = run_sc(&[ - action.to_string(), - SERVICE_NAME.to_string(), - "binPath=".to_string(), - bin_path, - "start=".to_string(), - "auto".to_string(), - "DisplayName=".to_string(), - SERVICE_DISPLAY_NAME.to_string(), - ])?; - if !out.status.success() { - return Err(format!( - "failed to {action} service: {}", - output_message(&out) - )); - } - Ok(()) -} - -#[cfg(windows)] -fn sc_start_service() -> Result<(), String> { - let out = run_sc(&["start".to_string(), SERVICE_NAME.to_string()])?; - if out.status.success() { - return Ok(()); - } - let text = format!( - "{}\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - if text.contains("1056") { - return Ok(()); - } - Err(format!("failed to start service: {}", output_message(&out))) -} - -#[cfg(windows)] -fn sc_stop_service() -> Result<(), String> { - let out = run_sc(&["stop".to_string(), SERVICE_NAME.to_string()])?; - if out.status.success() { - return Ok(()); - } - let text = format!( - "{}\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - if text.contains("1062") { - return Ok(()); - } - Err(format!("failed to stop service: {}", output_message(&out))) -} - -#[cfg(windows)] -async fn wait_for_service_state(expected_running: bool) -> Result<(), String> { - for _ in 0..20 { - if let Ok(Some(state)) = service_query_state() { - if expected_running && state == "running" { - return Ok(()); - } - if !expected_running && state == "stopped" { - return Ok(()); - } - } - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - } - Err(format!( - "service did not reach {} state within timeout", - if expected_running { - "running" - } else { - "stopped" - } - )) -} - -#[cfg(windows)] -async fn send_ipc_request(req: &crate::backend::service_host::IpcRequest) -> Result<(), String> { - send_ipc_request_within(req, std::time::Duration::ZERO).await -} - -/// Like [`send_ipc_request`] but keeps retrying to open the pipe until -/// `connect_timeout` elapses. Rides out the window where the service host is -/// still starting (fast-boot autostart leaves the service StartPending with its -/// pipe not yet listening), so the core start doesn't spuriously fail. -#[cfg(windows)] -async fn send_ipc_request_within( - req: &crate::backend::service_host::IpcRequest, - connect_timeout: std::time::Duration, -) -> Result<(), String> { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::windows::named_pipe::ClientOptions; - - let deadline = std::time::Instant::now() + connect_timeout; - let mut client = loop { - match ClientOptions::new().open(crate::backend::service_host::IPC_PIPE_NAME) { - Ok(c) => break c, - // ERROR_FILE_NOT_FOUND (pipe not up yet) / ERROR_PIPE_BUSY: the host - // is still coming up. Retry until the deadline, then surface the error. - Err(e) - if std::time::Instant::now() < deadline - && matches!(e.raw_os_error(), Some(2) | Some(231)) => - { - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - Err(e) => return Err(e.to_string()), - } - }; - - let req_str = - serde_json::to_string(req).map_err(|_e| "failed to serialize IPC request".to_string())?; - client - .write_all(req_str.as_bytes()) - .await - .map_err(|e| e.to_string())?; - - let mut buf = vec![0u8; 4096]; - let n = client.read(&mut buf).await.map_err(|e| e.to_string())?; - let msg = String::from_utf8_lossy(&buf[..n]); - - if let Ok(res) = serde_json::from_str::(&msg) { - match res { - crate::backend::service_host::IpcResponse::Ok - | crate::backend::service_host::IpcResponse::Pong => Ok(()), - crate::backend::service_host::IpcResponse::Error { message } => Err(message), - } - } else { - Err("invalid IPC response".to_string()) - } -} - -#[cfg(windows)] -fn run_elevated_bat(bat_cmd: &str) -> Result<(), String> { - use std::os::windows::process::CommandExt; - - let id = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis().to_string()) - .unwrap_or_default(); - - let tmp_bat = std::env::temp_dir().join(format!("nyx_svc_{}.bat", id)); - std::fs::write(&tmp_bat, bat_cmd).map_err(|e| e.to_string())?; - - let ps1_cmd = format!( - "Start-Process -FilePath '{}' -Verb RunAs -WindowStyle Hidden -Wait", - tmp_bat.display().to_string().replace('\'', "''") - ); - let tmp_ps1 = std::env::temp_dir().join(format!("nyx_svc_{}.ps1", id)); - std::fs::write(&tmp_ps1, ps1_cmd).map_err(|e| e.to_string())?; - - let out = std::process::Command::new("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-WindowStyle", - "Hidden", - "-File", - &tmp_ps1.to_string_lossy(), - ]) - .creation_flags(0x08000000) - .output() - .map_err(|e| e.to_string())?; - - let _ = std::fs::remove_file(&tmp_bat); - let _ = std::fs::remove_file(&tmp_ps1); - - if !out.status.success() { - return Err(format!( - "service (elevated) failed: {}", - output_message(&out) - )); - } - Ok(()) -} - -#[cfg(windows)] -fn install_service_elevated() -> Result<(), String> { - let action = match service_query_state()? { - Some(_) => "config", - None => "create", - }; - let bin_path = build_service_binpath()?; - - let bat_cmd = format!( - "@echo off\r\nchcp 65001 > nul\r\nsc.exe {action} \"{SERVICE_NAME}\" binPath= \"{bin_path}\" start= auto DisplayName= \"{SERVICE_DISPLAY_NAME}\"\r\nif errorlevel 1 exit /b 1\r\nsc.exe start \"{SERVICE_NAME}\"\r\nif errorlevel 1 exit /b 1\r\n", - action = action, - SERVICE_NAME = SERVICE_NAME, - bin_path = bin_path.replace('"', "\\\""), - SERVICE_DISPLAY_NAME = SERVICE_DISPLAY_NAME - ); - - run_elevated_bat(&bat_cmd) -} - -/// Ensures the service is running and mihomo started inside it, syncing the -/// controller URL + API client on success. -#[cfg(windows)] -async fn start_windows_service() -> Result<(), String> { - let binary = ensure_core_binary().await?; - let (config, url) = ensure_runtime_config().await?; - let work_dir = config - .parent() - .unwrap_or(std::path::Path::new("")) - .to_string_lossy() - .into_owned(); - - let state = service_query_state()?; - if state != Some("running".to_string()) { - if !elevation::is_elevated() { - let bat_cmd = format!( - "@echo off\r\nchcp 65001 > nul\r\nsc.exe start \"{SERVICE_NAME}\"\r\nif errorlevel 1 exit /b 1\r\n", - SERVICE_NAME = SERVICE_NAME - ); - run_elevated_bat(&bat_cmd)?; - } else { - sc_start_service()?; - } - wait_for_service_state(true).await?; - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - } - - let req = crate::backend::service_host::IpcRequest::StartCore { - binary: binary.to_string_lossy().into_owned(), - work_dir, - config: config.to_string_lossy().into_owned(), - max_log_days: read_max_log_days(), - }; - send_ipc_request_within(&req, std::time::Duration::from_secs(8)).await?; - sync_controller(&url, &config)?; - Ok(()) -} - -/// Reads the controller URL from the live runtime config (if not already set), -/// initializes the API client, and probes `/version` to confirm reachability. -pub async fn is_mihomo_running() -> bool { - let mut url = manager::controller_url(); - if url.is_empty() { - if let Ok(cm) = mihomo_rs::ConfigManager::with_home(dirs::data_dir()) { - if let Ok(config) = cm.get_current_path().await { - if let Ok(content) = tokio::fs::read_to_string(&config).await { - if let Ok(val) = serde_yaml::from_str::(&content) { - if let Some(addr) = val.get("external-controller").and_then(|v| v.as_str()) - { - if addr.starts_with("http") { - url = addr.to_string(); - } else if addr.starts_with(':') { - url = format!("http://127.0.0.1{addr}"); - } else { - url = format!("http://{addr}"); - } - manager::set_controller_url(url.clone()); - let _ = api::init_client(&url, read_secret_from_config(&config)); - } - } - } - } - } - } - if url.is_empty() { - return false; - } - let version_url = format!("{url}/version"); - reqwest::Client::builder() - .no_proxy() - .timeout(std::time::Duration::from_secs(2)) - .build() - .unwrap_or_default() - .get(&version_url) - .send() - .await - .map(|r| r.status().is_success()) - .unwrap_or(false) -} - -pub async fn service_status() -> Result { - #[cfg(windows)] - { - let status = service_query_state()?; - Ok(match status { - None => "not-installed".to_string(), - Some(s) if s == "running" => "running".to_string(), - Some(s) if s == "stopped" => "stopped".to_string(), - Some(_) => "unknown".to_string(), - }) - } - - #[cfg(not(windows))] - { - if !manager::core_installed().await { - return Ok("not-installed".to_string()); - } - if is_mihomo_running().await { - Ok("running".to_string()) - } else { - Ok("stopped".to_string()) - } - } -} - -pub async fn test_service_connection() -> Result { - #[cfg(windows)] - { - if service_query_state()? != Some("running".to_string()) { - return Ok(false); - } - let req = crate::backend::service_host::IpcRequest::Ping; - if send_ipc_request(&req).await.is_err() { - return Ok(false); - } - } - Ok(is_mihomo_running().await) -} - -pub async fn install_service() -> Result<(), String> { - #[cfg(windows)] - { - if !elevation::is_elevated() { - install_service_elevated()?; - } else { - ensure_service_installed_args()?; - let _ = sc_start_service(); - } - wait_for_service_state(true).await?; - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - Ok(()) - } - - #[cfg(not(windows))] - { - manager::install_core().await.map_err(|e| e.to_string()) - } -} - -pub async fn uninstall_service() -> Result<(), String> { - #[cfg(windows)] - { - let state = service_query_state()?; - if state.is_none() { - return Ok(()); - } - if state.as_deref() == Some("running") { - let _ = send_ipc_request(&crate::backend::service_host::IpcRequest::StopCore).await; - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - } - if !elevation::is_elevated() { - let bat_cmd = format!( - "@echo off\r\nsc.exe stop \"{SERVICE_NAME}\"\r\nsc.exe delete \"{SERVICE_NAME}\"\r\nif errorlevel 1 exit /b 1\r\n", - SERVICE_NAME = SERVICE_NAME - ); - run_elevated_bat(&bat_cmd)?; - } else { - sc_stop_service()?; - wait_for_service_state(false).await?; - let out = run_sc(&["delete".to_string(), SERVICE_NAME.to_string()])?; - if !out.status.success() { - return Err(format!( - "failed to delete service: {}", - output_message(&out) - )); - } - } - Ok(()) - } - - #[cfg(not(windows))] - { - manager::stop_core().await.map_err(|e| e.to_string()) - } -} - -/// Starts the core (service on Windows, direct spawn elsewhere), initializing -/// the API client on success. -pub async fn start_service() -> Result<(), String> { - #[cfg(windows)] - { - if service_query_state()?.is_none() { - return Err("service is not installed".to_string()); - } - start_windows_service().await - } - - #[cfg(not(windows))] - { - manager::start_core() - .await - .map(|_| ()) - .map_err(|e| e.to_string()) - } -} - -pub async fn restart_service() -> Result<(), String> { - #[cfg(windows)] - { - if service_query_state()?.is_none() { - return Err("service is not installed".to_string()); - } - start_windows_service().await - } - - #[cfg(not(windows))] - { - manager::restart_core() - .await - .map(|_| ()) - .map_err(|e| e.to_string()) - } -} - -#[cfg(windows)] -pub async fn stop_service_for_update() -> Result<(), String> { - let state = match service_query_state() { - Ok(s) => s, - Err(_) => return Ok(()), - }; - if state.is_none() { - return Ok(()); - } - if state.as_deref() == Some("running") { - let _ = send_ipc_request(&crate::backend::service_host::IpcRequest::StopCore).await; - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - if !elevation::is_elevated() { - let bat_cmd = format!( - "@echo off\r\nsc.exe stop \"{SERVICE_NAME}\"\r\n", - SERVICE_NAME = SERVICE_NAME - ); - run_elevated_bat(&bat_cmd)?; - } else { - sc_stop_service()?; - } - let _ = wait_for_service_state(false).await; - } - Ok(()) -} - -/// Fully stops the running core, whether it's managed by the Windows service -/// (StopCore over IPC) or a locally-spawned process. Used on "quit with core". -pub async fn stop_core_complete() -> Result<(), String> { - #[cfg(windows)] - { - if service_query_state().ok().flatten() == Some("running".to_string()) { - let req = crate::backend::service_host::IpcRequest::StopCore; - let _ = send_ipc_request(&req).await; - return Ok(()); - } - } - let _ = manager::stop_core().await; - Ok(()) -} - -pub async fn stop_service() -> Result<(), String> { - #[cfg(windows)] - { - if service_query_state()?.is_none() { - return Ok(()); - } - if service_query_state()? == Some("running".to_string()) { - let req = crate::backend::service_host::IpcRequest::StopCore; - let _ = send_ipc_request(&req).await; - } - Ok(()) - } - - #[cfg(not(windows))] - { - manager::stop_core().await.map_err(|e| e.to_string()) - } -} diff --git a/src/backend/service_host.rs b/src/backend/service_host.rs deleted file mode 100644 index 2ddbf9e..0000000 --- a/src/backend/service_host.rs +++ /dev/null @@ -1,361 +0,0 @@ -#[cfg(windows)] -pub mod imp { - use serde::{Deserialize, Serialize}; - use std::ffi::{c_void, OsString}; - use std::io::Write; - use std::path::{Path, PathBuf}; - use std::sync::mpsc; - use std::time::Duration; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; - use tokio::process::Child; - use windows::core::{BOOL, PCWSTR}; - use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; - use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; - use windows_service::define_windows_service; - use windows_service::service::{ - ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, - ServiceType, - }; - use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; - use windows_service::service_dispatcher; - - const SERVICE_NAME: &str = "Nyx Service"; - pub const IPC_PIPE_NAME: &str = r"\\.\pipe\nyx_mihomo_ipc"; - - fn build_pipe_security_attributes() -> Option { - let sddl: Vec = "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGWGX;;;AU)\0" - .encode_utf16() - .collect(); - let mut psd = PSECURITY_DESCRIPTOR::default(); - unsafe { - ConvertStringSecurityDescriptorToSecurityDescriptorW( - PCWSTR::from_raw(sddl.as_ptr()), - 1, - &mut psd as *mut _, - None, - ) - .ok()?; - } - Some(SECURITY_ATTRIBUTES { - nLength: std::mem::size_of::() as u32, - lpSecurityDescriptor: psd.0, - bInheritHandle: BOOL(0), - }) - } - - fn create_pipe_server(first: bool) -> std::io::Result { - let mut opts = ServerOptions::new(); - opts.first_pipe_instance(first); - match build_pipe_security_attributes() { - Some(mut sa) => unsafe { - opts.create_with_security_attributes_raw( - IPC_PIPE_NAME, - &mut sa as *mut SECURITY_ATTRIBUTES as *mut c_void, - ) - }, - None => opts.create(IPC_PIPE_NAME), - } - } - - #[derive(Serialize, Deserialize, Debug)] - #[serde(tag = "action", rename_all = "SCREAMING_SNAKE_CASE")] - pub enum IpcRequest { - StartCore { - binary: String, - work_dir: String, - config: String, - #[serde(default = "default_max_log_days")] - max_log_days: u32, - }, - StopCore, - Ping, - } - - fn default_max_log_days() -> u32 { - 7 - } - - #[derive(Serialize, Deserialize, Debug)] - #[serde(tag = "status", rename_all = "SCREAMING_SNAKE_CASE")] - pub enum IpcResponse { - Ok, - Error { message: String }, - Pong, - } - - pub fn log_to_file(msg: &str) { - let log_dir = Path::new("C:\\ProgramData\\Nyx"); - let today = chrono::Local::now().format("%Y-%m-%d").to_string(); - let log_path = log_dir.join(format!("{}.log", today)); - let _ = std::fs::create_dir_all(log_dir); - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - { - use std::time::SystemTime; - let ts = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let _ = writeln!(f, "[{ts}] {msg}"); - } - } - - fn clean_old_logs(max_days: u32) { - if max_days == 0 { - return; - } - - let today = chrono::Local::now().date_naive(); - let cutoff = match today.checked_sub_signed(chrono::Duration::days(max_days as i64 - 1)) { - Some(d) => d, - None => return, - }; - - let log_dir = Path::new("C:\\ProgramData\\Nyx"); - let Ok(entries) = std::fs::read_dir(log_dir) else { - return; - }; - - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) != Some("log") { - continue; - } - let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { - continue; - }; - let Ok(file_date) = chrono::NaiveDate::parse_from_str(stem, "%Y-%m-%d") else { - continue; - }; - if file_date < cutoff { - let _ = std::fs::remove_file(&path); - } - } - } - - define_windows_service!(ffi_service_main, service_main); - - pub fn maybe_run_as_service_from_args() -> Option { - if !std::env::args().any(|a| a == "--nyx-service") { - return None; - } - - if let Err(e) = service_dispatcher::start(SERVICE_NAME, ffi_service_main) { - log_to_file(&format!("failed to start service dispatcher: {e}")); - return Some(1); - } - - Some(0) - } - - fn service_main(_arguments: Vec) { - if let Err(e) = run_service() { - log_to_file(&format!("service runtime error: {e}")); - } - } - - fn run_service() -> windows_service::Result<()> { - log_to_file("service_main: initializing 24/7 service"); - - let (stop_tx, stop_rx) = mpsc::channel::<()>(); - - let status_handle = service_control_handler::register( - SERVICE_NAME, - move |control_event| match control_event { - ServiceControl::Stop => { - let _ = stop_tx.send(()); - ServiceControlHandlerResult::NoError - } - ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, - _ => ServiceControlHandlerResult::NotImplemented, - }, - )?; - - status_handle.set_service_status(ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::Running, - controls_accepted: ServiceControlAccept::STOP, - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::from_secs(0), - process_id: None, - })?; - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("Failed to build tokio runtime"); - - rt.block_on(async move { - let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); - let (child_tx, child_rx) = tokio::sync::mpsc::channel::(10); - - let manager_handle = tokio::spawn(async move { - let mut current_child: Option = None; - let mut rx = child_rx; - - while let Some(req) = rx.recv().await { - match req { - IpcRequest::StartCore { - binary, - work_dir, - config, - max_log_days, - } => { - drop(tokio::task::spawn_blocking(move || { - clean_old_logs(max_log_days); - })); - - if let Some(mut child) = current_child.take() { - let _ = child.kill().await; - } - - let mut cmd = tokio::process::Command::new(&binary); - cmd.arg("-d") - .arg(if work_dir.is_empty() { - PathBuf::from(&config) - .parent() - .unwrap_or(Path::new("")) - .to_string_lossy() - .into_owned() - } else { - work_dir.clone() - }) - .arg("-f") - .arg(&config) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - - cmd.creation_flags(0x08000000); - - match cmd.spawn() { - Ok(child) => { - log_to_file(&format!( - "service manager: spawned mihomo pid={:?}", - child.id() - )); - current_child = Some(child); - } - Err(e) => { - log_to_file(&format!( - "service manager: failed to spawn mihomo: {}", - e - )); - } - } - } - IpcRequest::StopCore => { - if let Some(mut child) = current_child.take() { - let pid = child.id().unwrap_or(0); - log_to_file(&format!( - "service manager: stopping mihomo pid={}", - pid - )); - let _ = child.kill().await; - - if pid > 0 { - use std::os::windows::process::CommandExt; - let _ = std::process::Command::new("taskkill") - .args(["/F", "/PID", &pid.to_string()]) - .creation_flags(0x08000000) - .output(); - } - } - } - _ => {} - } - } - - if let Some(mut child) = current_child.take() { - let _ = child.kill().await; - } - }); - - let server_task = tokio::spawn(async move { - loop { - let mut server = match create_pipe_server(true) { - Ok(s) => s, - Err(_) => match create_pipe_server(false) { - Ok(s) => s, - Err(e) => { - log_to_file(&format!("failed to create pipe: {}", e)); - tokio::time::sleep(Duration::from_secs(1)).await; - continue; - } - }, - }; - - if let Err(e) = server.connect().await { - log_to_file(&format!("pipe connect error: {}", e)); - continue; - } - - let mut buf = vec![0u8; 8192]; - match server.read(&mut buf).await { - Ok(n) if n > 0 => { - let msg = String::from_utf8_lossy(&buf[..n]); - if let Ok(req) = serde_json::from_str::(&msg) { - log_to_file(&format!("Got IPC request: {:?}", req)); - match req { - IpcRequest::StartCore { .. } | IpcRequest::StopCore => { - let _ = child_tx.send(req).await; - let res = serde_json::to_string(&IpcResponse::Ok).unwrap(); - let _ = server.write_all(res.as_bytes()).await; - } - IpcRequest::Ping => { - let res = - serde_json::to_string(&IpcResponse::Pong).unwrap(); - let _ = server.write_all(res.as_bytes()).await; - } - } - } else { - let res = serde_json::to_string(&IpcResponse::Error { - message: "Invalid request payload".to_string(), - }) - .unwrap(); - let _ = server.write_all(res.as_bytes()).await; - } - } - _ => {} - } - } - }); - - let _ = tokio::task::spawn_blocking(move || { - let _ = stop_rx.recv(); - let _ = shutdown_tx.blocking_send(()); - }) - .await; - - let _ = shutdown_rx.recv().await; - log_to_file("service stopping..."); - server_task.abort(); - manager_handle.abort(); - }); - - status_handle.set_service_status(ServiceStatus { - service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::Stopped, - controls_accepted: ServiceControlAccept::empty(), - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::from_secs(0), - process_id: None, - })?; - - log_to_file("service successfully stopped"); - Ok(()) - } -} - -#[cfg(windows)] -pub use imp::{maybe_run_as_service_from_args, IpcRequest, IpcResponse, IPC_PIPE_NAME}; - -#[cfg(not(windows))] -pub fn maybe_run_as_service_from_args() -> Option { - None -} diff --git a/src/backend/startup.rs b/src/backend/startup.rs index 49ff565..1c07832 100644 --- a/src/backend/startup.rs +++ b/src/backend/startup.rs @@ -1,17 +1,9 @@ use serde_json::Value; -use crate::backend::{dirs, manager, mihomo, service}; +use crate::backend::core::CoreError; +use crate::backend::{core, dirs, mihomo}; -fn read_app_config_sync() -> Value { - let path = dirs::app_config_path(); - std::fs::read_to_string(&path) - .ok() - .and_then(|s| serde_yaml::from_str::(&s).ok()) - .unwrap_or_default() -} - -/// One-time data-dir cleanup: rename legacy `config.yaml` → `app-config.yaml` -/// and drop the stale `window-state.json`. Safe on every launch. +/// Renames the legacy `config.yaml` and drops the stale window state. pub fn migrate_data_dir() { let new = dirs::app_config_path(); let old = dirs::legacy_app_config_path(); @@ -28,7 +20,6 @@ pub fn migrate_data_dir() { } } -/// Writes the default `app-config.yaml` if none exists yet (first run). pub fn ensure_default_app_config() { let config_path = dirs::app_config_path(); if config_path.exists() { @@ -50,6 +41,7 @@ pub fn ensure_default_app_config() { "maxLogDays": 7, "delayTestConcurrency": 50, "sysProxy": { "enable": false, "mode": "manual" }, + "connectionMode": "tun", "hosts": [], "core": "mihomo", "corePermissionMode": "service" @@ -59,42 +51,59 @@ pub fn ensure_default_app_config() { log::info!("created default app config"); } -/// Brings the core up and confirms it is reachable. On `Ok`, the API client is -/// initialized, `controller_url()` is populated, and selections are restored. -pub async fn start_core_flow() -> Result<(), String> { - ensure_default_app_config(); - - let app_cfg = read_app_config_sync(); - let use_service_mode = - cfg!(windows) && app_cfg["corePermissionMode"].as_str().unwrap_or("service") == "service"; +pub async fn normalize_connection_mode() { + let cfg = read_app_config_sync(); + let sysproxy_on = cfg + .get("sysProxy") + .and_then(|v| v.get("enable")) + .and_then(Value::as_bool) + .unwrap_or(false); - if use_service_mode { - match service::service_status().await { - Ok(status) if status == "running" || status == "stopped" => { - service::start_service().await?; - } - Ok(status) if status == "not-installed" => { - return Err( - "Service mode is enabled, but the Nyx service is not installed".to_string(), - ); - } - Ok(status) => { - return Err(format!("Unexpected service status: {status}")); - } - Err(e) => return Err(e), + let mode = match cfg.get("connectionMode").and_then(Value::as_str) { + Some(mode) => mode.to_string(), + None => { + let tun_on = cfg + .get("lastConnected") + .and_then(Value::as_bool) + .unwrap_or(false); + let inferred = if sysproxy_on && !tun_on { + "sysproxy" + } else { + "tun" + }; + log::info!("[startup] no connection mode saved, assuming {inferred}"); + let _ = crate::backend::config::patch_app_config( + serde_json::json!({ "connectionMode": inferred }), + ) + .await; + inferred.to_string() } - } else { - let selected_core = app_cfg["core"].as_str().unwrap_or("mihomo"); - manager::ensure_core_installed(selected_core) - .await - .map_err(|e| e.to_string())?; - manager::start_core().await.map_err(|e| e.to_string())?; + }; + + if mode != "sysproxy" && sysproxy_on { + log::info!("[startup] connection mode is {mode}, clearing the saved system proxy"); + let _ = crate::backend::config::patch_app_config( + serde_json::json!({ "sysProxy": { "enable": false } }), + ) + .await; } +} +/// Brings the core up and confirms it answers; also restores proxy selections. +pub async fn start_core_flow() -> Result<(), CoreError> { + ensure_default_app_config(); + core::start().await?; mihomo::restore_proxy_selections().await; log::info!( "[startup] core flow complete, controller={}", - manager::controller_url() + core::controller_url() ); Ok(()) } + +pub fn read_app_config_sync() -> Value { + std::fs::read_to_string(dirs::app_config_path()) + .ok() + .and_then(|s| serde_yaml::from_str::(&s).ok()) + .unwrap_or_default() +} diff --git a/src/backend/streaming.rs b/src/backend/streaming.rs index 973ed41..142a31c 100644 --- a/src/backend/streaming.rs +++ b/src/backend/streaming.rs @@ -2,18 +2,14 @@ use futures_util::StreamExt; use std::time::Duration; use tokio::sync::mpsc::UnboundedSender; -use crate::backend::manager; +use crate::backend::core; -/// One update produced by the streaming loops. #[derive(Debug, Clone)] pub enum StreamEvent { - /// Raw payload of `GET /connections`. Connections(serde_json::Value), - /// One parsed log line `{ "type": , "payload": }`. Log(serde_json::Value), } -/// Polls `/connections` once per second and forwards each snapshot. pub async fn stream_connections(tx: UnboundedSender) { let client = reqwest::Client::builder() .no_proxy() @@ -25,7 +21,7 @@ pub async fn stream_connections(tx: UnboundedSender) { if tx.is_closed() { return; } - let url = manager::controller_url(); + let url = core::controller_url(); if url.is_empty() { tokio::time::sleep(Duration::from_secs(1)).await; continue; @@ -33,10 +29,10 @@ pub async fn stream_connections(tx: UnboundedSender) { let connections_url = format!("{url}/connections"); match client.get(&connections_url).send().await { Ok(resp) => { - if let Ok(data) = resp.json::().await { - if tx.send(StreamEvent::Connections(data)).is_err() { - return; - } + if let Ok(data) = resp.json::().await + && tx.send(StreamEvent::Connections(data)).is_err() + { + return; } } Err(e) => log::debug!("[streaming] connections poll error: {e}"), @@ -51,7 +47,7 @@ pub async fn stream_logs(tx: UnboundedSender) { if tx.is_closed() { return; } - let url = manager::controller_url(); + let url = core::controller_url(); if url.is_empty() { tokio::time::sleep(Duration::from_secs(1)).await; continue; @@ -81,10 +77,10 @@ pub async fn stream_logs(tx: UnboundedSender) { if line.is_empty() { continue; } - if let Ok(entry) = serde_json::from_str::(&line) { - if tx.send(StreamEvent::Log(entry)).is_err() { - return; - } + if let Ok(entry) = serde_json::from_str::(&line) + && tx.send(StreamEvent::Log(entry)).is_err() + { + return; } } } diff --git a/src/backend/sysproxy.rs b/src/backend/sysproxy.rs index c48dec5..6626ce8 100644 --- a/src/backend/sysproxy.rs +++ b/src/backend/sysproxy.rs @@ -1,158 +1,67 @@ -/// LAN/loopback ranges excluded from the system proxy (Windows only). +use nyx_sysproxy::Sysproxy; + +/// LAN/loopback ranges excluded from the system proxy. #[cfg(target_os = "windows")] const BYPASS: &str = ";localhost;127.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;192.168.*"; +#[cfg(not(target_os = "windows"))] +const BYPASS: &str = "localhost,127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"; -/// `127.0.0.1:` from the live core config (falls back to 7890). -async fn proxy_addr() -> String { - let port = crate::backend::api::get_config() +async fn mixed_port() -> u16 { + crate::backend::api::get_config() .await .ok() .and_then(|c| c["mixed-port"].as_u64()) - .unwrap_or(7890); - format!("127.0.0.1:{port}") + .map(|p| p as u16) + .unwrap_or(7890) } -/// Applies (or removes) the OS system proxy pointing at the mixed port. pub async fn apply(enable: bool, affect_vpn: bool) { - let addr = if enable { - proxy_addr().await - } else { - String::new() - }; - set_proxy(enable, &addr, affect_vpn); + let port = if enable { mixed_port().await } else { 0 }; + set_proxy(enable, port, affect_vpn); } /// Removes the OS system proxy. Sync — safe to call on the quit path. pub fn clear() { - set_proxy(false, "", false); + // Clear the same scope we set, or dial-up/VPN entries keep a dead proxy. + let affect_vpn = crate::backend::config::app_config_bool("affectVPNConnections"); + set_proxy(false, 0, affect_vpn); } -#[cfg(target_os = "windows")] -fn set_proxy(enable: bool, proxy_addr: &str, affect_vpn: bool) { - use winreg::{enums::HKEY_CURRENT_USER, RegKey}; - - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; - let Ok((key, _)) = hkcu.create_subkey(path) else { - log::warn!("[sysproxy] could not open Internet Settings key"); - return; +fn set_proxy(enable: bool, port: u16, affect_vpn: bool) { + let proxy = Sysproxy { + enable, + host: "127.0.0.1".to_string(), + port, + bypass: BYPASS.to_string(), }; - - let _ = key.set_value("ProxyEnable", &(enable as u32)); - if enable { - let _ = key.set_value("ProxyServer", &proxy_addr.to_string()); - let _ = key.set_value("ProxyOverride", &BYPASS.to_string()); + if let Err(e) = proxy.set_system_proxy_with(affect_vpn) { + log::warn!("[sysproxy] set(enable={enable}) failed: {e}"); } - if affect_vpn { - set_windows_connections(&hkcu, path, enable, proxy_addr); - } - - unsafe { - #[link(name = "wininet")] - extern "system" { - fn InternetSetOptionW( - h: *mut std::ffi::c_void, - opt: u32, - buf: *mut std::ffi::c_void, - len: u32, - ) -> i32; - } - // INTERNET_OPTION_SETTINGS_CHANGED, then INTERNET_OPTION_REFRESH. - InternetSetOptionW(std::ptr::null_mut(), 39, std::ptr::null_mut(), 0); - InternetSetOptionW(std::ptr::null_mut(), 37, std::ptr::null_mut(), 0); - } + #[cfg(target_os = "linux")] + set_env_proxy(enable, port); } -#[cfg(target_os = "windows")] -fn set_windows_connections(hkcu: &winreg::RegKey, path: &str, enable: bool, proxy_addr: &str) { - let connections_path = format!("{path}\\Connections"); - let Ok((conns, _)) = hkcu.create_subkey(&connections_path) else { - return; - }; - for (name, val) in conns.enum_values().flatten() { - if val.vtype != winreg::enums::REG_BINARY || val.bytes.len() < 12 { - continue; - } - let mut head = val.bytes[..12].to_vec(); - let counter = u32::from_le_bytes(head[4..8].try_into().unwrap_or([0; 4])); - head[4..8].copy_from_slice(&(counter + 1).to_le_bytes()); - - let mut bytes = head; - if enable { - bytes[8] = 0x03; // DIRECT | PROXY - bytes.extend_from_slice(&(proxy_addr.len() as u32).to_le_bytes()); - bytes.extend_from_slice(proxy_addr.as_bytes()); - bytes.extend_from_slice(&(BYPASS.len() as u32).to_le_bytes()); - bytes.extend_from_slice(BYPASS.as_bytes()); - } else { - bytes[8] = 0x01; // DIRECT only - bytes.extend_from_slice(&[0u8; 8]); - } - bytes.extend_from_slice(&[0u8; 36]); - let _ = conns.set_raw_value( - &name, - &winreg::RegValue { - vtype: winreg::enums::REG_BINARY, - bytes: bytes.into(), - }, - ); - } -} - -/// Whether the current desktop reliably honors the GSettings system proxy -/// (GNOME and relatives). Elsewhere only apps that read the proxy env vars are -/// affected, so the system-proxy toggle is partial — surface that in the UI. +/// Whether this desktop honours the proxy we write (GNOME family, KDE). +/// Elsewhere only apps reading the env vars follow it, so the toggle is partial. #[cfg(target_os = "linux")] pub fn session_honors_proxy() -> bool { std::env::var("XDG_CURRENT_DESKTOP") .map(|d| { let d = d.to_ascii_lowercase(); - ["gnome", "unity", "cinnamon", "mate", "budgie", "pop"] - .iter() - .any(|k| d.contains(k)) + [ + "gnome", "unity", "cinnamon", "mate", "budgie", "pop", "kde", "plasma", + ] + .iter() + .any(|k| d.contains(k)) }) .unwrap_or(false) } +/// Pushes the proxy env into the systemd user manager and D-Bus activation +/// environment. Does not reach already-running apps. #[cfg(target_os = "linux")] -fn set_proxy(enable: bool, proxy_addr: &str, _affect_vpn: bool) { - set_gsettings_proxy(enable, proxy_addr); - set_env_proxy(enable, proxy_addr); -} - -#[cfg(target_os = "linux")] -fn set_gsettings_proxy(enable: bool, proxy_addr: &str) { - let run = |args: &[&str]| { - let _ = std::process::Command::new("gsettings").args(args).status(); - }; - if enable { - let (host, port) = proxy_addr.split_once(':').unwrap_or(("127.0.0.1", "7890")); - run(&["set", "org.gnome.system.proxy", "mode", "manual"]); - for schema in ["http", "https"] { - run(&[ - "set", - &format!("org.gnome.system.proxy.{schema}"), - "host", - host, - ]); - run(&[ - "set", - &format!("org.gnome.system.proxy.{schema}"), - "port", - port, - ]); - } - } else { - run(&["set", "org.gnome.system.proxy", "mode", "none"]); - } -} - -/// Best-effort: push proxy env into the systemd user manager and the D-Bus -/// activation environment so newly launched / dbus-activated apps inherit it. -/// Does NOT reach already-running apps or ones the compositor execs directly. -#[cfg(target_os = "linux")] -fn set_env_proxy(enable: bool, proxy_addr: &str) { +fn set_env_proxy(enable: bool, port: u16) { const NAMES: [&str; 6] = [ "http_proxy", "https_proxy", @@ -162,7 +71,7 @@ fn set_env_proxy(enable: bool, proxy_addr: &str) { "NO_PROXY", ]; if enable { - let url = format!("http://{proxy_addr}"); + let url = format!("http://127.0.0.1:{port}"); let no = "localhost,127.0.0.1,::1"; let assignments = [ format!("http_proxy={url}"), @@ -187,6 +96,3 @@ fn set_env_proxy(enable: bool, proxy_addr: &str) { .status(); } } - -#[cfg(not(any(target_os = "windows", target_os = "linux")))] -fn set_proxy(_enable: bool, _proxy_addr: &str, _affect_vpn: bool) {} diff --git a/src/backend/updater.rs b/src/backend/updater.rs index f3aa157..e732fc6 100644 --- a/src/backend/updater.rs +++ b/src/backend/updater.rs @@ -2,62 +2,54 @@ use serde::{Deserialize, Serialize}; const REPO_OWNER: &str = "BX-Team"; const REPO_NAME: &str = "Nyx"; +const BIN_NAME: &str = "nyx"; #[cfg(windows)] const WINDOWS_ASSET: &str = "Nyx-x86_64-windows.zip"; +#[cfg(not(windows))] +const LINUX_TARGET: &str = "x86_64-linux"; -/// A newer release available for install. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateInfo { pub version: String, pub changelog: String, } -/// Returns the newest release if it is newer than the running build, else `None`. +fn latest_release() -> Result { + self_update::backends::github::Update::configure() + .repo_owner(REPO_OWNER) + .repo_name(REPO_NAME) + .bin_name(BIN_NAME) + .current_version(self_update::cargo_crate_version!()) + .build() + .map_err(|e| e.to_string())? + .get_latest_release() + .map_err(|e| e.to_string()) +} + pub async fn check() -> Result, String> { tokio::task::spawn_blocking(|| { - let releases = self_update::backends::github::ReleaseList::configure() - .repo_owner(REPO_OWNER) - .repo_name(REPO_NAME) - .build() - .map_err(|e| e.to_string())? - .fetch() - .map_err(|e| e.to_string())?; - - let Some(latest) = releases.into_iter().next() else { - return Ok(None); - }; + let latest = latest_release()?; let current = self_update::cargo_crate_version!(); let newer = self_update::version::bump_is_greater(current, &latest.version) .map_err(|e| e.to_string())?; - if newer { - Ok(Some(UpdateInfo { - version: latest.version, - changelog: latest.body.unwrap_or_default(), - })) - } else { - Ok(None) + if !newer { + return Ok(None); } + Ok(Some(UpdateInfo { + version: latest.version, + changelog: latest.body.unwrap_or_default(), + })) }) .await .map_err(|e| e.to_string())? } -/// Downloads and installs the newest release. -/// -/// Returns `true` when the relaunch is handled externally and the caller should -/// just leave it to the helper, or `false` when the binary was replaced in place -/// and the caller should relaunch itself. -/// -/// On Windows the app lives in `Program Files` and the running `nyx.exe` (plus -/// the background `--nyx-service` process) lock the file, so a non-elevated -/// in-place replace fails with "access denied". Instead we download + unpack the -/// new binary to a temp dir and hand off to a short elevated script that kills -/// the running processes, overwrites the installed exe, and relaunches it. +/// Installs the newest release. `true` means an elevated helper will relaunch +/// (Windows, where the running exe is locked); `false` means the caller should. pub async fn download_and_install() -> Result { #[cfg(windows)] { - let _ = crate::backend::service::stop_service_for_update().await; windows_update().await } @@ -77,13 +69,16 @@ pub async fn download_and_install() -> Result { )); } tokio::task::spawn_blocking(|| { + let tag = format!("v{}", latest_release()?.version); self_update::backends::github::Update::configure() .repo_owner(REPO_OWNER) .repo_name(REPO_NAME) - .target("x86_64-linux") - .bin_name("nyx") + .target(LINUX_TARGET) + .bin_name(BIN_NAME) .current_version(self_update::cargo_crate_version!()) + .target_version_tag(&tag) .no_confirm(true) + .show_output(false) .show_download_progress(false) .build() .map_err(|e| e.to_string())? @@ -93,6 +88,12 @@ pub async fn download_and_install() -> Result { }) .await .map_err(|e| e.to_string())??; + + let _ = crate::backend::core::stop().await; + crate::backend::sysproxy::clear(); + if let Err(e) = nyx_service::restart_service().await { + log::warn!("[updater] could not restart the service after the update: {e}"); + } Ok(false) } } @@ -133,6 +134,9 @@ async fn windows_update() -> Result { )); } + let _ = crate::backend::core::stop().await; + crate::backend::sysproxy::clear(); + tokio::task::spawn_blocking(move || finalize_windows_update(&bytes)) .await .map_err(|e| e.to_string())? @@ -140,15 +144,7 @@ async fn windows_update() -> Result { #[cfg(windows)] fn windows_asset_url() -> Result { - let releases = self_update::backends::github::ReleaseList::configure() - .repo_owner(REPO_OWNER) - .repo_name(REPO_NAME) - .build() - .map_err(|e| e.to_string())? - .fetch() - .map_err(|e| e.to_string())?; - let latest = releases.into_iter().next().ok_or("no releases found")?; - latest + latest_release()? .assets .into_iter() .find(|a| a.name.eq_ignore_ascii_case(WINDOWS_ASSET)) @@ -186,11 +182,10 @@ fn spawn_elevated_swap( ) -> Result<(), String> { use std::os::windows::process::CommandExt; - // Kill the running nyx processes (the service is already stopped) so the exe - // unlocks, overwrite it, then relaunch via explorer so the new process runs - // de-elevated rather than inheriting this script's admin token. let script = format!( - "@echo off\r\nchcp 65001 >nul\r\ntaskkill /F /IM nyx.exe >nul 2>&1\r\nset /a n=0\r\n:retry\r\ncopy /Y \"{new}\" \"{inst}\" >nul 2>&1\r\nif not errorlevel 1 goto done\r\nset /a n+=1\r\nif %n% geq 30 goto done\r\ntimeout /t 1 /nobreak >nul\r\ngoto retry\r\n:done\r\nstart \"\" explorer.exe \"{inst}\"\r\n", + "@echo off\r\nchcp 65001 >nul\r\nsc stop \"{svc}\" >nul 2>&1\r\ntaskkill /F /PID {pid} >nul 2>&1\r\nset /a n=0\r\n:retry\r\ncopy /Y \"{new}\" \"{inst}\" >nul 2>&1\r\nif not errorlevel 1 goto done\r\nset /a n+=1\r\nif %n% geq 30 goto done\r\ntimeout /t 1 /nobreak >nul\r\ngoto retry\r\n:done\r\nsc start \"{svc}\" >nul 2>&1\r\nstart \"\" explorer.exe \"{inst}\"\r\n", + svc = nyx_service::SERVICE_NAME, + pid = std::process::id(), new = new_exe.display(), inst = install_exe.display(), ); diff --git a/src/lib.rs b/src/lib.rs index 496cff5..597f381 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,9 +7,8 @@ mod backend; mod ui; pub fn run() { - // When launched as the Windows service host (`--nyx-service`), run the - // dispatcher and exit before touching any GUI. - if let Some(code) = backend::maybe_run_as_service_from_args() { + // Service host and the elevated install/uninstall helpers run without a GUI. + if let Some(code) = nyx_service::maybe_run_service_mode() { std::process::exit(code); } @@ -26,7 +25,6 @@ pub fn run() { let app = gpui_platform::application().with_assets(app::assets::Assets); - // Tidy the data dir (rename legacy config) before anything reads it. backend::startup::migrate_data_dir(); let silent = backend::config::app_config_bool("silentStart"); @@ -35,9 +33,7 @@ pub fn run() { gpui_component::Theme::change(gpui_component::ThemeMode::Dark, None, cx); ui::theme::apply(cx); app::state::AppState::init(cx); - // Closing the window hides Nyx to the tray instead of quitting; the tray - // keeps the process alive, so don't auto-quit when no window is open. - // (Windows hides the native window in place and keeps its own logic.) + // Closing the window hides Nyx to the tray; the tray keeps the process alive. #[cfg(not(windows))] cx.set_quit_mode(gpui::QuitMode::Explicit); if !silent { diff --git a/src/ui/flags.rs b/src/ui/flags.rs index a3ddbdd..ff4ec21 100644 --- a/src/ui/flags.rs +++ b/src/ui/flags.rs @@ -1,14 +1,13 @@ -use gpui::{div, img, px, AnyElement, IntoElement, ParentElement, SharedString, Styled}; +use gpui::{AnyElement, IntoElement, ParentElement, SharedString, Styled, div, img, px}; -/// A piece of a display name: plain text, or a flag rendered as an SVG image -/// (gpui paints color emoji blank on Windows, so 🇸🇪 → `assets/flags/se.svg`). +/// A display-name segment: plain text, or a flag drawn as an SVG (gpui paints +/// color emoji blank on Windows). enum Seg { Text(String), Flag(&'static str), } -/// Maps a Unicode regional-indicator codepoint (U+1F1E6–U+1F1FF) to its ASCII -/// letter, e.g. 🇸 → `S`. +/// Regional-indicator codepoint (U+1F1E6–U+1F1FF) to its ASCII letter. fn regional_letter(c: char) -> Option { let cp = c as u32; (0x1F1E6..=0x1F1FF) @@ -16,8 +15,6 @@ fn regional_letter(c: char) -> Option { .then(|| (b'A' + (cp - 0x1F1E6) as u8) as char) } -/// Splits a name into text + flag segments; regional-indicator pairs validated -/// by the `emojis` crate become flags. fn segments(name: &str) -> Vec { let chars: Vec = name.chars().collect(); let mut out: Vec = Vec::new(); @@ -47,8 +44,7 @@ fn segments(name: &str) -> Vec { out } -/// Lowercases the two flag letters into a static asset basename (`se`, `us`, …), -/// or `""` if we ship no SVG for that code. +/// Two flag letters to a shipped asset basename, or `""` if we have no SVG. fn iso_to_code(a: char, b: char) -> &'static str { CODES .iter() @@ -57,15 +53,12 @@ fn iso_to_code(a: char, b: char) -> &'static str { .unwrap_or("") } -/// Whether `name` contains at least one renderable flag. pub(crate) fn has_flag(name: &str) -> bool { segments(name) .iter() .any(|s| matches!(s, Seg::Flag(c) if !c.is_empty())) } -/// Renders a display name with inline flag images. Falls back to plain text when -/// the name has no flags, so callers keep their existing truncation behaviour. pub(crate) fn render_name(name: &str) -> AnyElement { if !has_flag(name) { return name.to_string().into_any_element(); @@ -89,6 +82,27 @@ pub(crate) fn render_name(name: &str) -> AnyElement { div().child(row).into_any_element() } +/// Lowercase codes we ship a flag SVG for. Generated from `assets/flags/`. +const CODES: &[&str] = &[ + "ad", "ae", "af", "ag", "ai", "al", "am", "ao", "aq", "ar", "as", "at", "au", "aw", "ax", "az", + "ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bl", "bm", "bn", "bo", "bq", "br", "bs", + "bt", "bv", "bw", "by", "bz", "ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", + "co", "cp", "cr", "cu", "cv", "cw", "cx", "cy", "cz", "de", "dg", "dj", "dk", "dm", "do", "dz", + "ec", "ee", "eg", "eh", "er", "es", "et", "eu", "fi", "fj", "fk", "fm", "fo", "fr", "ga", "gb", + "gd", "ge", "gf", "gg", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", + "gy", "hk", "hm", "hn", "hr", "ht", "hu", "ic", "id", "ie", "il", "im", "in", "io", "iq", "ir", + "is", "it", "je", "jm", "jo", "jp", "ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", + "kz", "la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly", "ma", "mc", "md", "me", + "mf", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", + "mx", "my", "mz", "na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz", "om", + "pa", "pc", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "ps", "pt", "pw", "py", "qa", + "re", "ro", "rs", "ru", "rw", "sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", + "sm", "sn", "so", "sr", "ss", "st", "sv", "sx", "sy", "sz", "tc", "td", "tf", "tg", "th", "tj", + "tk", "tl", "tm", "tn", "to", "tr", "tt", "tv", "tw", "tz", "ua", "ug", "um", "un", "us", "uy", + "uz", "va", "vc", "ve", "vg", "vi", "vn", "vu", "wf", "ws", "xk", "xx", "ye", "yt", "za", "zm", + "zw", +]; + #[cfg(test)] mod tests { use super::*; @@ -112,25 +126,3 @@ mod tests { assert!(!has_flag("auto-fallback")); } } - -/// Lowercase codes we ship a flag SVG for (flag-icons 4x3, ISO 3166-1 alpha-2 -/// plus a few exceptional reservations). Generated from `assets/flags/`. -const CODES: &[&str] = &[ - "ad", "ae", "af", "ag", "ai", "al", "am", "ao", "aq", "ar", "as", "at", "au", "aw", "ax", "az", - "ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bl", "bm", "bn", "bo", "bq", "br", "bs", - "bt", "bv", "bw", "by", "bz", "ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", - "co", "cp", "cr", "cu", "cv", "cw", "cx", "cy", "cz", "de", "dg", "dj", "dk", "dm", "do", "dz", - "ec", "ee", "eg", "eh", "er", "es", "et", "eu", "fi", "fj", "fk", "fm", "fo", "fr", "ga", "gb", - "gd", "ge", "gf", "gg", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", - "gy", "hk", "hm", "hn", "hr", "ht", "hu", "ic", "id", "ie", "il", "im", "in", "io", "iq", "ir", - "is", "it", "je", "jm", "jo", "jp", "ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", - "kz", "la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly", "ma", "mc", "md", "me", - "mf", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", - "mx", "my", "mz", "na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz", "om", - "pa", "pc", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "ps", "pt", "pw", "py", "qa", - "re", "ro", "rs", "ru", "rw", "sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", - "sm", "sn", "so", "sr", "ss", "st", "sv", "sx", "sy", "sz", "tc", "td", "tf", "tg", "th", "tj", - "tk", "tl", "tm", "tn", "to", "tr", "tt", "tv", "tw", "tz", "ua", "ug", "um", "un", "us", "uy", - "uz", "va", "vc", "ve", "vg", "vi", "vn", "vu", "wf", "ws", "xk", "xx", "ye", "yt", "za", "zm", - "zw", -]; diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index 6afad02..09c4371 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -1,10 +1,11 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, px, rgb, rgba, Context, InteractiveElement, IntoElement, ParentElement, Styled, Window, + Context, InteractiveElement, IntoElement, ParentElement, Styled, Window, div, px, rgb, rgba, }; use gpui_component::{ + StyledExt, button::{Button, ButtonVariants}, - h_flex, v_flex, StyledExt, + h_flex, v_flex, }; use rust_i18n::t; use serde_json::json; @@ -12,7 +13,7 @@ use serde_json::json; use crate::app::runtime; use crate::backend; use crate::ui::root::{ - brand_gradient, NyxApp, Route, SettingsSub, BLUE, CARD_BG, CARD_BORDER, GREEN, SUBTLE, TEXT, + BLUE, CARD_BG, CARD_BORDER, GREEN, NyxApp, Route, SUBTLE, SettingsSub, TEXT, brand_gradient, }; const LAST_STEP: u8 = 3; @@ -22,7 +23,6 @@ impl NyxApp { self.onboarding_step.is_some() } - /// Advances the welcome flow, routing to the screen the next step is about. pub(crate) fn onboarding_advance(&mut self, window: &mut Window, cx: &mut Context) { let step = self.onboarding_step.unwrap_or(0); if step >= LAST_STEP { @@ -43,7 +43,6 @@ impl NyxApp { cx.notify(); } - /// Dismisses the flow and records it so it never shows again. pub(crate) fn onboarding_finish(&mut self, cx: &mut Context) { self.onboarding_step = None; self.state.update(cx, |s, _| s.onboarding_active = false); @@ -53,7 +52,7 @@ impl NyxApp { cx.notify(); } - pub(crate) fn render_onboarding(&self, cx: &mut Context) -> impl IntoElement { + pub(crate) fn render_onboarding(&self, cx: &mut Context) -> impl IntoElement + use<> { let step = self.onboarding_step.unwrap_or(0); let (title, body, hint) = match step { 0 => ( @@ -66,16 +65,11 @@ impl NyxApp { t!("onboarding.profileBody"), Some(t!("onboarding.profileHint")), ), - 2 if cfg!(windows) => ( + 2 => ( t!("onboarding.serviceTitle"), t!("onboarding.serviceBody"), Some(t!("onboarding.serviceHint")), ), - 2 => ( - t!("onboarding.tunTitle"), - t!("onboarding.tunBody"), - Some(t!("onboarding.tunHint")), - ), _ => ( t!("onboarding.proxyTitle"), t!("onboarding.proxyBody"), @@ -169,8 +163,7 @@ impl NyxApp { ), ); - // Welcome step is a centered modal; action steps float bottom-right with - // no scrim, keeping the real UI behind clickable. + // Action steps float bottom-right with no scrim, keeping the UI clickable. if step == 0 { div() .id("onboarding-scrim") diff --git a/src/ui/pages/connections.rs b/src/ui/pages/connections.rs index 46e484f..8571a38 100644 --- a/src/ui/pages/connections.rs +++ b/src/ui/pages/connections.rs @@ -1,18 +1,18 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, img, px, rgb, rgba, Context, InteractiveElement, IntoElement, ParentElement, SharedString, - StatefulInteractiveElement, Styled, + Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, img, px, rgb, rgba, }; use gpui_component::{ - h_flex, input::Input, notification::Notification, tooltip::Tooltip, v_flex, Icon, IconName, - StyledExt, WindowExt, + Icon, IconName, StyledExt, WindowExt, h_flex, input::Input, notification::Notification, + tooltip::Tooltip, v_flex, }; use rust_i18n::t; use crate::app::state::{ConnItem, ConnProcess}; use crate::ui::root::{ - fmt_bytes, NyxApp, BLUE, CARD_BG, CARD_BORDER, GREEN, GREEN_HI, MUTED2, MUTED3, PANEL_BG, RED, - RED_HI, SUBTLE, TEXT, + BLUE, CARD_BG, CARD_BORDER, GREEN, GREEN_HI, MUTED2, MUTED3, NyxApp, PANEL_BG, RED, RED_HI, + SUBTLE, TEXT, fmt_bytes, }; /// A small fixed palette for process avatars, picked by name hash. @@ -28,7 +28,7 @@ fn avatar_color(name: &str) -> u32 { } impl NyxApp { - pub(crate) fn render_connections(&self, cx: &mut Context) -> impl IntoElement { + pub(crate) fn render_connections(&self, cx: &mut Context) -> impl IntoElement + use<> { let content = match self.conns_detail.clone() { Some(name) => { let st = self.state.read(cx); @@ -49,8 +49,7 @@ impl NyxApp { ) } - /// The process list (with header totals + filter box). - fn render_conn_list(&self, cx: &mut Context) -> impl IntoElement { + fn render_conn_list(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let total_up = st.total_up; let total_down = st.total_down; @@ -233,8 +232,7 @@ impl NyxApp { .child(body) } - /// One clickable process card. - fn conn_card(&self, p: ConnProcess, cx: &mut Context) -> impl IntoElement { + fn conn_card(&self, p: ConnProcess, cx: &mut Context) -> impl IntoElement + use<> { let letter = p .name .chars() @@ -339,13 +337,12 @@ impl NyxApp { })) } - /// Detail view: the connections belonging to a single process. fn render_conn_detail( &self, name: SharedString, proc: Option, cx: &mut Context, - ) -> impl IntoElement { + ) -> impl IntoElement + use<> { let conns = proc.as_ref().map(|p| p.conns.clone()).unwrap_or_default(); let (up, down) = proc.as_ref().map(|p| (p.up, p.down)).unwrap_or((0, 0)); let count = conns.len(); @@ -459,8 +456,12 @@ impl NyxApp { v_flex().size_full().child(header).child(body) } - /// A connection row in the detail view, clickable to open the metadata popup. - fn conn_detail_row(&self, idx: usize, c: ConnItem, cx: &mut Context) -> impl IntoElement { + fn conn_detail_row( + &self, + idx: usize, + c: ConnItem, + cx: &mut Context, + ) -> impl IntoElement + use<> { let item = c.clone(); conn_detail_row_inner(c) .id(SharedString::from(format!("conn-row-{idx}"))) @@ -472,9 +473,8 @@ impl NyxApp { })) } - /// The per-connection metadata popup. Each value is click-to-copy, with a - /// chip that copies the matching rule fragment (`IP-CIDR,…` etc.). - fn render_conn_popup(&self, c: ConnItem, cx: &mut Context) -> impl IntoElement { + /// Per-connection metadata popup; every value is click-to-copy. + fn render_conn_popup(&self, c: ConnItem, cx: &mut Context) -> impl IntoElement + use<> { let host_only = strip_port(c.host.as_ref()); let host_frag = if host_only.chars().any(|ch| ch.is_ascii_alphabetic()) { Some(format!("DOMAIN-SUFFIX,{host_only}")) @@ -665,7 +665,6 @@ impl NyxApp { ) } - /// A titled card of detail rows; empty-value rows (and all-empty sections) are dropped. fn detail_section( &self, key: &'static str, @@ -705,14 +704,13 @@ impl NyxApp { .into_any_element() } - /// One `label : value` row; click-to-copy with an optional rule-fragment chip. fn kv_row( &self, key: &'static str, idx: usize, row: Kv, cx: &mut Context, - ) -> impl IntoElement { + ) -> impl IntoElement + use<> { let Kv { label, value, frag } = row; let copy_value = value.clone(); let value_id = SharedString::from(format!("kv-{key}-{idx}")); @@ -794,7 +792,6 @@ impl NyxApp { } } -/// The visual body of a connection row (no interactivity). fn conn_detail_row_inner(c: ConnItem) -> gpui::Div { v_flex() .gap_1p5() @@ -865,7 +862,6 @@ fn conn_detail_row_inner(c: ConnItem) -> gpui::Div { ) } -/// A green pill showing a connection count. fn count_badge(n: usize) -> impl IntoElement { div() .px(px(6.)) @@ -877,14 +873,12 @@ fn count_badge(n: usize) -> impl IntoElement { .child(n.to_string()) } -/// An up/down arrow + value pair (mono-ish, colored). -fn updown(color: u32, arrow: &str, value: &str) -> impl IntoElement { +fn updown(color: u32, arrow: &str, value: &str) -> impl IntoElement + use<> { let s: SharedString = format!("{arrow} {value}").into(); div().text_xs().text_color(rgb(color)).child(s) } -/// A row spec for the detail popup: label, value, and an optional rule fragment -/// (the chip that copies e.g. `IP-CIDR,1.2.3.4/32`). +/// A detail-popup row: label, value, and the rule fragment its chip copies. struct Kv { label: String, value: String, @@ -899,7 +893,6 @@ fn kv(label: impl Into, value: String, frag: Option) -> Kv { } } -/// Strips a trailing `:port` from a host (leaves bare IPv6 addresses untouched). fn strip_port(host: &str) -> &str { if let Some(idx) = host.rfind(':') { let after_digits = host[idx + 1..].chars().all(|c| c.is_ascii_digit()); diff --git a/src/ui/pages/home.rs b/src/ui/pages/home.rs index 09e4cba..f4f58b2 100644 --- a/src/ui/pages/home.rs +++ b/src/ui/pages/home.rs @@ -1,21 +1,43 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, px, rgb, rgba, Context, InteractiveElement, IntoElement, ParentElement, SharedString, - StatefulInteractiveElement, Styled, Window, + Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, Window, div, px, rgb, rgba, }; use gpui_component::{ + Icon, IconName, Sizable, StyledExt, button::{Button, ButtonVariants}, - h_flex, v_flex, Icon, IconName, Sizable, StyledExt, + h_flex, v_flex, }; use rust_i18n::t; use serde_json::Value; use crate::ui::root::{ - fmt_bytes, power_on_bg, NyxApp, CARD_BG, CARD_BORDER, GOOD, GREEN, MUTED, PANEL_BG, STROKE, - TEXT, + CARD_BG, CARD_BORDER, CONTROL_BG, CONTROL_BORDER, GOOD, GREEN, MUTED, NyxApp, PANEL_BG, RED, + RED_HI, STROKE, SUBTLE, TEXT, fmt_bytes, power_on_bg, }; -/// Parsed subscription stats from a profile's `extra` + `announce`. +/// Names the cause when the core refused to start, instead of leaving the +/// power button silently dead. +fn render_core_failure(key: &'static str, detail: SharedString) -> impl IntoElement { + v_flex() + .mx_1() + .mb_2() + .p_3() + .gap_1() + .rounded(px(8.)) + .bg(rgba((RED << 8) | 0x1A)) + .border_1() + .border_color(rgb(RED)) + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(RED_HI)) + .child(t!(key).to_string()), + ) + .child(div().text_xs().text_color(rgb(MUTED)).child(detail)) +} + struct SubStats { has_traffic: bool, used: u64, @@ -81,8 +103,7 @@ fn sub_stats(item: &Option) -> SubStats { } } -/// The profile's `supportUrl`, if any, plus whether it points at Telegram -/// (`tg:` scheme or a `t.me` / `telegram` host) — used to pick the button icon. +/// The profile's `supportUrl` plus whether it is a Telegram link, for the icon. fn support_link(item: &Option) -> Option<(String, bool)> { let url = item .as_ref() @@ -114,7 +135,8 @@ impl NyxApp { if st.profiles.is_empty() { return self.render_home_empty(cx).into_any_element(); } - let tun = st.tun_enabled; + let connected = st.tun_enabled || st.app_flag("sysProxy.enable"); + let mode = crate::app::actions::connection_mode(cx); let total_up = st.total_up; let total_down = st.total_down; let profile_name = st @@ -133,19 +155,24 @@ impl NyxApp { .connected_since .map(|t| t.elapsed().as_secs()) .unwrap_or(0); - let status = if tun { + let status = if connected { t!("pages.home.connected").to_string() } else { t!("pages.home.disconnected").to_string() }; let support = support_link(&st.current_profile_item); + let failure = st + .core_status + .failed() + .map(|(kind, detail)| (crate::app::state::failure_key(kind), detail.clone())); let main = v_flex() .flex_1() .min_w_0() .h_full() - .child(self.render_topbar(&profile_name, tun, &status, support, cx)) + .child(self.render_topbar(&profile_name, connected, &status, mode, support, cx)) + .children(failure.map(|(key, detail)| render_core_failure(key, detail))) .child( v_flex() .flex_1() @@ -160,8 +187,8 @@ impl NyxApp { .text_color(rgb(TEXT)) .child(status.to_uppercase()), ) - .child(self.render_power_button(tun, cx)) - .when(tun, |this| { + .child(self.render_power_button(connected, cx)) + .when(connected, |this| { this.child( div() .text_lg() @@ -186,8 +213,7 @@ impl NyxApp { .into_any_element() } - /// Fresh-install home - fn render_home_empty(&self, cx: &mut Context) -> impl IntoElement { + fn render_home_empty(&self, cx: &mut Context) -> impl IntoElement + use<> { v_flex() .size_full() .items_center() @@ -230,15 +256,58 @@ impl NyxApp { ) } + fn render_mode_switch(&self, mode: &str, cx: &mut Context) -> impl IntoElement + use<> { + h_flex() + .gap(px(2.)) + .p(px(2.)) + .rounded(px(8.)) + .bg(rgb(CONTROL_BG)) + .border_1() + .border_color(rgb(CONTROL_BORDER)) + .children( + [ + ( + crate::app::actions::MODE_TUN, + t!("pages.home.modeTun").to_string(), + ), + ( + crate::app::actions::MODE_SYSPROXY, + t!("pages.home.modeSysProxy").to_string(), + ), + ] + .into_iter() + .map(|(value, label)| { + let on = mode == value; + div() + .id(SharedString::from(format!("home-mode-{value}"))) + .px(px(10.)) + .py(px(3.)) + .rounded(px(6.)) + .text_xs() + .cursor_pointer() + .when(on, |t| t.bg(rgb(GREEN)).text_color(rgb(0x0B1014))) + .when(!on, |t| t.text_color(rgb(SUBTLE))) + .child(label) + .on_click( + cx.listener(move |this, _, _, cx| { + this.select_connection_mode(value, cx) + }), + ) + }), + ) + } + + #[allow(clippy::too_many_arguments)] fn render_topbar( &self, profile: &str, - tun: bool, + connected: bool, status: &str, + mode: &str, support: Option<(String, bool)>, cx: &mut Context, - ) -> impl IntoElement { - let dot = if tun { GOOD } else { MUTED }; + ) -> impl IntoElement + use<> { + let dot = if connected { GOOD } else { MUTED }; let support_btn = support.map(|(url, is_telegram)| { let icon = if is_telegram { Icon::empty().path("icons/telegram.svg") @@ -282,30 +351,42 @@ impl NyxApp { ) .child( h_flex() - .gap_1() - .children(support_btn) - .child( - Button::new("home-refresh") - .ghost() - .small() - .icon(Icon::empty().path("icons/refresh.svg")) - .tooltip(t!("tooltips.refresh").to_string()) - .on_click(cx.listener(|this, _, _, cx| this.refresh_subscription(cx))), - ) + .gap_2() + .items_center() + .child(self.render_mode_switch(mode, cx)) .child( - Button::new("home-stats-toggle") - .ghost() - .small() - .icon(IconName::ChevronRight) - .tooltip(t!("tooltips.toggleStats").to_string()) - .on_click(cx.listener(|this, _, _, cx| this.toggle_stats(cx))), + h_flex() + .gap_1() + .children(support_btn) + .child( + Button::new("home-refresh") + .ghost() + .small() + .icon(Icon::empty().path("icons/refresh.svg")) + .tooltip(t!("tooltips.refresh").to_string()) + .on_click( + cx.listener(|this, _, _, cx| this.refresh_subscription(cx)), + ), + ) + .child( + Button::new("home-stats-toggle") + .ghost() + .small() + .icon(IconName::ChevronRight) + .tooltip(t!("tooltips.toggleStats").to_string()) + .on_click(cx.listener(|this, _, _, cx| this.toggle_stats(cx))), + ), ), ) } - fn render_power_button(&self, tun: bool, cx: &mut Context) -> impl IntoElement { - let icon_color = if tun { 0x06140C } else { MUTED }; - let inner = if tun { + fn render_power_button( + &self, + connected: bool, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let icon_color = if connected { 0x06140C } else { MUTED }; + let inner = if connected { div().size(px(116.)).rounded_full().bg(power_on_bg()) } else { div() @@ -315,7 +396,11 @@ impl NyxApp { .border_1() .border_color(rgb(CARD_BORDER)) }; - let icon = if tun { IconName::Pause } else { IconName::Play }; + let icon = if connected { + IconName::Pause + } else { + IconName::Play + }; div() .id("power-button") .size(px(116.)) @@ -328,7 +413,7 @@ impl NyxApp { .justify_center() .child(Icon::new(icon).large().text_color(rgb(icon_color))), ) - .on_click(cx.listener(|this, _, _, cx| this.toggle_tun(cx))) + .on_click(cx.listener(|this, _, _, cx| this.toggle_connection(cx))) } } @@ -365,7 +450,7 @@ fn render_speeds(up: u64, down: u64) -> impl IntoElement { fn render_proxy_card( current: Option<(String, String)>, cx: &mut Context, -) -> impl IntoElement { +) -> impl IntoElement + use<> { let (name, kind) = current.unwrap_or_else(|| ("—".to_string(), String::new())); div().flex().justify_center().child( div() @@ -413,7 +498,7 @@ fn render_proxy_card( ) } -fn section_header(text: &str) -> impl IntoElement { +fn section_header(text: &str) -> impl IntoElement + use<> { div() .text_xs() .font_semibold() @@ -431,7 +516,7 @@ fn stat_tile() -> gpui::Div { .p_3() } -fn render_stats(stats: &SubStats) -> impl IntoElement { +fn render_stats(stats: &SubStats) -> impl IntoElement + use<> { let mut col = v_flex().gap_3(); if stats.has_traffic { diff --git a/src/ui/pages/logs.rs b/src/ui/pages/logs.rs index c38ae7a..f2830f8 100644 --- a/src/ui/pages/logs.rs +++ b/src/ui/pages/logs.rs @@ -1,18 +1,17 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, px, rgb, rgba, Context, InteractiveElement, IntoElement, ParentElement, SharedString, - StatefulInteractiveElement, Styled, + Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, rgba, }; -use gpui_component::{h_flex, v_flex, Icon, StyledExt}; +use gpui_component::{Icon, StyledExt, h_flex, v_flex}; use rust_i18n::t; use crate::app::state::LogLine; use crate::ui::root::{ - LogFilter, NyxApp, AMBER, BLUE, CARD_BG, CARD_BORDER, GREEN, MUTED2, MUTED4, PANEL_BG, RED, + AMBER, BLUE, CARD_BG, CARD_BORDER, GREEN, LogFilter, MUTED2, MUTED4, NyxApp, PANEL_BG, RED, SUBTLE, TEXT, }; -/// Visual treatment for a log level: chip text, chip colour, message colour. fn level_style(level: &str) -> (&'static str, u32, u32) { match level { "warning" | "warn" => ("WARN", AMBER, 0xE6C98A), @@ -32,7 +31,7 @@ fn matches(filter: LogFilter, level: &str) -> bool { } impl NyxApp { - pub(crate) fn render_logs(&self, cx: &mut Context) -> impl IntoElement { + pub(crate) fn render_logs(&self, cx: &mut Context) -> impl IntoElement + use<> { let filter = self.logs_filter; let st = self.state.read(cx); // Cap rendered rows (no virtualization) — newest 400 after filtering. @@ -48,8 +47,7 @@ impl NyxApp { .rev() .collect(); - // Autoscroll on new lines, keyed off monotonic `log_seq` (not `logs.len()`, - // which saturates at the ring-buffer cap). + // Autoscroll keys off monotonic `log_seq`, since `logs.len()` saturates at the cap. let seq = st.log_seq; if self.logs_seen.get() != seq { self.logs_seen.set(seq); @@ -117,8 +115,7 @@ impl NyxApp { v_flex().size_full().child(header).child(console) } - /// The Все / Info / Warn / Error level filter. - fn logs_segmented(&self, cx: &mut Context) -> impl IntoElement { + fn logs_segmented(&self, cx: &mut Context) -> impl IntoElement + use<> { let cur = self.logs_filter; let pill = |label: String, f: LogFilter, cx: &mut Context| { div() diff --git a/src/ui/pages/mod.rs b/src/ui/pages/mod.rs index d0ac9a3..4c8ad9b 100644 --- a/src/ui/pages/mod.rs +++ b/src/ui/pages/mod.rs @@ -6,10 +6,9 @@ mod proxies; mod rules; mod settings; -pub(crate) use rules::{rule_example, RULE_TYPES}; +pub(crate) use rules::{RULE_TYPES, rule_example}; -/// Converts a [`gpui::Keystroke`] into a `global-hotkey` accelerator string -/// (e.g. `Ctrl+Shift+KeyT`), or `None` if it can't be mapped. +/// A [`gpui::Keystroke`] as a `global-hotkey` accelerator (`Ctrl+Shift+KeyT`). pub(crate) fn keystroke_to_accel(ks: &gpui::Keystroke) -> Option { let code = key_to_code(&ks.key)?; let m = &ks.modifiers; @@ -34,8 +33,7 @@ pub(crate) fn keystroke_to_accel(ks: &gpui::Keystroke) -> Option { Some(accel) } -/// Maps a gpui logical key (e.g. `t`, `1`, `f5`, `-`) to a W3C `Code` name -/// (`KeyT`, `Digit1`, `F5`, `Minus`). +/// A gpui logical key to its W3C `Code` name (`t` → `KeyT`, `1` → `Digit1`). fn key_to_code(key: &str) -> Option { if key.len() == 1 { let c = key.chars().next().unwrap(); @@ -60,12 +58,11 @@ fn key_to_code(key: &str) -> Option { _ => None, }; } - if let Some(n) = key.strip_prefix('f').or_else(|| key.strip_prefix('F')) { - if let Ok(num) = n.parse::() { - if (1..=24).contains(&num) { - return Some(format!("F{num}")); - } - } + if let Some(n) = key.strip_prefix('f').or_else(|| key.strip_prefix('F')) + && let Ok(num) = n.parse::() + && (1..=24).contains(&num) + { + return Some(format!("F{num}")); } match key { "space" => Some("Space".into()), diff --git a/src/ui/pages/profiles.rs b/src/ui/pages/profiles.rs index e63949e..d2561ac 100644 --- a/src/ui/pages/profiles.rs +++ b/src/ui/pages/profiles.rs @@ -1,21 +1,23 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, px, rgb, rgba, Context, InteractiveElement, IntoElement, ParentElement, SharedString, - StatefulInteractiveElement, Styled, Window, + Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, Window, div, px, rgb, rgba, }; use gpui_component::{ + Disableable, Icon, IconName, Sizable, StyledExt, button::{Button, ButtonVariants}, h_flex, input::Input, + menu::{DropdownMenu, PopupMenuItem}, tooltip::Tooltip, - v_flex, Disableable, Icon, IconName, Sizable, StyledExt, + v_flex, }; use rust_i18n::t; use crate::app::state::ProfileItem; use crate::ui::root::{ - brand_gradient, fmt_bytes, NyxApp, ACTIVE_CARD_BG, ACTIVE_CARD_BORDER, BLUE, CARD_BG, - CARD_BORDER, CONTROL_BG, CONTROL_BORDER, DIVIDER, GREEN, MUTED, MUTED2, RED, SUBTLE, TEXT, + ACTIVE_CARD_BG, ACTIVE_CARD_BORDER, BLUE, CARD_BG, CARD_BORDER, CONTROL_BG, CONTROL_BORDER, + DIVIDER, GREEN, MUTED, MUTED2, NyxApp, RED, SUBTLE, TEXT, brand_gradient, fmt_bytes, }; /// Days until `expire` (unix ts), or a localized "never" when unset. @@ -33,7 +35,7 @@ impl NyxApp { &self, _window: &mut Window, cx: &mut Context, - ) -> impl IntoElement { + ) -> impl IntoElement + use<> { let profiles = self.state.read(cx).profiles.clone(); let count = profiles.len(); @@ -113,7 +115,11 @@ impl NyxApp { v_flex().size_full().child(header).child(list) } - fn render_profile_card(&self, p: ProfileItem, cx: &mut Context) -> impl IntoElement { + fn render_profile_card( + &self, + p: ProfileItem, + cx: &mut Context, + ) -> impl IntoElement + use<> { let is_remote = p.kind.as_ref() == "remote"; let current = p.is_current; let id = p.id.to_string(); @@ -207,7 +213,8 @@ impl NyxApp { .child(self.profile_actions(&id, &name, current, is_remote, cx)) } - /// The per-card icon action cluster (activate / refresh / edit / delete). + /// Activate and update stay icons; the ambiguous actions live in a labelled + /// menu, since "edit the subscription" and "edit the config" look alike. fn profile_actions( &self, id: &str, @@ -215,7 +222,7 @@ impl NyxApp { current: bool, is_remote: bool, cx: &mut Context, - ) -> impl IntoElement { + ) -> impl IntoElement + use<> { let mut row = h_flex().gap_1(); if !current { let aid = id.to_string(); @@ -243,52 +250,62 @@ impl NyxApp { .on_click(cx.listener(move |this, _, _, cx| this.update_profile(uid.clone(), cx))), ); } - let iid = id.to_string(); - row = row.child( - icon_btn( - &format!("info-{id}"), - Icon::empty().path("icons/link.svg"), - false, - ) - .tooltip(|window, cx| { - Tooltip::new(t!("tooltips.editInfo").to_string()).build(window, cx) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_profile_edit_info(iid.clone(), window, cx) - })), - ); - let eid = id.to_string(); - let ename = name.to_string(); - row = row.child( - icon_btn( - &format!("edit-{id}"), - Icon::empty().path("icons/square-pen.svg"), - false, - ) - .tooltip(|window, cx| Tooltip::new(t!("tooltips.edit").to_string()).build(window, cx)) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_profile_editor(eid.clone(), ename.clone(), window, cx) - })), - ); - let del = icon_btn( - &format!("del-{id}"), - Icon::empty().path("icons/trash-2.svg"), - current, - ); - let del = if current { - del - } else { - let did = id.to_string(); - del.tooltip(|window, cx| { - Tooltip::new(t!("tooltips.delete").to_string()).build(window, cx) - }) - .on_click(cx.listener(move |this, _, _, cx| this.delete_profile(did.clone(), cx))) - }; - row.child(del) + + let view = cx.entity(); + let (id, name) = (id.to_string(), name.to_string()); + row.child( + Button::new(SharedString::from(format!("prof-more-{id}"))) + .ghost() + .icon(Icon::new(IconName::Ellipsis)) + .dropdown_menu(move |menu, _window, _cx| { + let menu = menu + .item( + PopupMenuItem::new(t!("pages.profiles.menuSubscription").to_string()) + .icon(Icon::new(IconName::Globe)) + .on_click({ + let (id, view) = (id.clone(), view.clone()); + move |_, window, cx| { + let id = id.clone(); + view.update(cx, |this, cx| { + this.open_profile_edit_info(id, window, cx) + }); + } + }), + ) + .item( + PopupMenuItem::new(t!("pages.profiles.menuEditConfig").to_string()) + .icon(Icon::empty().path("icons/square-pen.svg")) + .on_click({ + let (id, name, view) = (id.clone(), name.clone(), view.clone()); + move |_, window, cx| { + let (id, name) = (id.clone(), name.clone()); + view.update(cx, |this, cx| { + this.open_profile_editor(id, name, window, cx) + }); + } + }), + ) + .separator(); + menu.item( + PopupMenuItem::new(t!("pages.profiles.menuDelete").to_string()) + .icon(Icon::empty().path("icons/trash-2.svg")) + .disabled(current) + .on_click({ + let (id, view) = (id.clone(), view.clone()); + move |_, _window, cx| { + let id = id.clone(); + view.update(cx, |this, cx| this.delete_profile(id, cx)); + } + }), + ) + }), + ) } - /// The "Add subscription" modal: Remote/Local toggle, URL or file picker, name, Import/Cancel. - pub(crate) fn render_profile_add_modal(&self, cx: &mut Context) -> impl IntoElement { + pub(crate) fn render_profile_add_modal( + &self, + cx: &mut Context, + ) -> impl IntoElement + use<> { let local = self.profile_add_local; let editing = self.profile_edit_id.is_some(); let busy = self.profile_add_busy; @@ -433,7 +450,6 @@ impl NyxApp { } } -/// A small labeled column wrapper for a modal form field. fn field_label(label: &str) -> gpui::Div { v_flex().gap_1p5().child( div() @@ -443,7 +459,6 @@ fn field_label(label: &str) -> gpui::Div { ) } -/// A Remote/Local source toggle pill. fn source_pill( label: &str, active: bool, @@ -465,8 +480,7 @@ fn source_pill( .on_click(on_click) } -/// A small uppercase chip (`ACTIVE`, `REMOTE`, …). -fn chip(label: &str, accent: bool) -> impl IntoElement { +fn chip(label: &str, accent: bool) -> impl IntoElement + use<> { div() .px(px(7.)) .py(px(2.)) @@ -479,7 +493,6 @@ fn chip(label: &str, accent: bool) -> impl IntoElement { .child(label.to_string()) } -/// A 32px bordered icon button used in the card action cluster. fn icon_btn(key: &str, icon: Icon, disabled: bool) -> gpui::Stateful { div() .id(SharedString::from(format!("prof-{key}"))) diff --git a/src/ui/pages/proxies.rs b/src/ui/pages/proxies.rs index d3a7e42..e15deb5 100644 --- a/src/ui/pages/proxies.rs +++ b/src/ui/pages/proxies.rs @@ -1,18 +1,17 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, px, rgb, rgba, Context, InteractiveElement, IntoElement, ParentElement, SharedString, - StatefulInteractiveElement, Styled, Window, + Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, Window, div, px, rgb, rgba, }; -use gpui_component::{h_flex, input::Input, tooltip::Tooltip, v_flex, Icon, IconName, StyledExt}; +use gpui_component::{Icon, IconName, StyledExt, h_flex, input::Input, tooltip::Tooltip, v_flex}; use rust_i18n::t; use crate::app::state::{ProxyGroup, ProxyNode}; use crate::ui::root::{ - delay_color, NyxApp, CARD_BG, CARD_BORDER, CONTROL_BG, CONTROL_BORDER, GREEN, GREEN_GLOW, - GREEN_HI, MUTED, MUTED2, PANEL_BG, SUBTLE, TEXT, + CARD_BG, CARD_BORDER, CONTROL_BG, CONTROL_BORDER, GREEN, GREEN_GLOW, GREEN_HI, MUTED, MUTED2, + NyxApp, PANEL_BG, SUBTLE, TEXT, delay_color, }; -/// Short uppercase label for a group's selection strategy. fn kind_label(kind: &str) -> String { match kind { "Selector" => "SELECT".into(), @@ -29,7 +28,7 @@ impl NyxApp { &self, _window: &mut Window, cx: &mut Context, - ) -> impl IntoElement { + ) -> impl IntoElement + use<> { let groups = self.state.read(cx).groups.clone(); let focused: Option = self @@ -118,7 +117,6 @@ impl NyxApp { ) .into_any_element() } else { - // Group list as its own left panel, reading as a selector. let list = v_flex() .w(px(228.)) .h_full() @@ -158,13 +156,12 @@ impl NyxApp { v_flex().size_full().child(header).child(body) } - /// One entry in the left group list. fn render_group_card( &self, group: &ProxyGroup, active: bool, cx: &mut Context, - ) -> impl IntoElement { + ) -> impl IntoElement + use<> { let name = group.name.clone(); let now = group.now.clone(); let count = group.all.len(); @@ -224,8 +221,11 @@ impl NyxApp { })) } - /// The 3-up node grid for the focused group. - fn render_node_grid(&self, group: &ProxyGroup, cx: &mut Context) -> impl IntoElement { + fn render_node_grid( + &self, + group: &ProxyGroup, + cx: &mut Context, + ) -> impl IntoElement + use<> { let group_name = group.name.to_string(); let now = group.now.to_string(); @@ -270,8 +270,7 @@ impl NyxApp { ) } - /// Search box + sort-by-latency and alive-only toggles above the node grid. - fn render_node_toolbar(&self, cx: &mut Context) -> impl IntoElement { + fn render_node_toolbar(&self, cx: &mut Context) -> impl IntoElement + use<> { let toggle = |id: &'static str, label: String, active: bool| { div() .id(SharedString::from(id)) @@ -334,7 +333,7 @@ impl NyxApp { node: &ProxyNode, now: &str, cx: &mut Context, - ) -> impl IntoElement { + ) -> impl IntoElement + use<> { let selected = node.name.as_ref() == now; let node_name = node.name.to_string(); let test_group = group.clone(); @@ -425,8 +424,7 @@ impl NyxApp { } } -/// A group's kind chip (`SELECT`, `URL-TEST`, …). -fn kind_chip(kind: &str) -> impl IntoElement { +fn kind_chip(kind: &str) -> impl IntoElement + use<> { div() .px(px(6.)) .py(px(2.)) @@ -437,7 +435,7 @@ fn kind_chip(kind: &str) -> impl IntoElement { .child(kind_label(kind)) } -fn seg_pill(label: &str, active: bool) -> impl IntoElement { +fn seg_pill(label: &str, active: bool) -> impl IntoElement + use<> { div() .px(px(12.)) .py(px(5.)) diff --git a/src/ui/pages/rules.rs b/src/ui/pages/rules.rs index 5c44896..f75d401 100644 --- a/src/ui/pages/rules.rs +++ b/src/ui/pages/rules.rs @@ -1,22 +1,23 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, px, rgb, rgba, Context, InteractiveElement, IntoElement, ParentElement, SharedString, - StatefulInteractiveElement, Styled, Window, + Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, Window, div, px, rgb, rgba, }; use gpui_component::{ + Disableable, Icon, IconName, Sizable, StyledExt, button::{Button, ButtonVariants}, h_flex, input::Input, select::Select, tooltip::Tooltip, - v_flex, Disableable, Icon, IconName, Sizable, StyledExt, + v_flex, }; use rust_i18n::t; use crate::app::state::Rule; use crate::ui::root::{ - NyxApp, CARD_BG, CARD_BORDER, CONTROL_BG, CONTROL_BORDER, DIVIDER, GREEN, GREEN_HI, MUTED2, - MUTED3, MUTED4, RED, RED_HI, SUBTLE, TEXT, + CARD_BG, CARD_BORDER, CONTROL_BG, CONTROL_BORDER, DIVIDER, GREEN, GREEN_HI, MUTED2, MUTED3, + MUTED4, NyxApp, RED, RED_HI, SUBTLE, TEXT, }; /// Rule types offered by the smart editor's type picker (mihomo rule set). @@ -56,7 +57,6 @@ pub(crate) const RULE_TYPES: &[&str] = &[ "MATCH", ]; -/// Example payload placeholder for a given rule type in the "add rule" form. pub(crate) fn rule_example(kind: &str) -> &'static str { match kind { "DOMAIN" => "example.com", @@ -113,8 +113,7 @@ pub(crate) fn rule_example(kind: &str) -> &'static str { } } -/// Reconstructs the rule string for a subscription rule (matches the override -/// `delete` format), e.g. `DOMAIN-SUFFIX,example.com,DIRECT`. +/// Rebuilds a subscription rule string in the override `delete` format. fn rule_to_string(r: &Rule) -> String { if r.kind.as_ref() == "MATCH" { format!("MATCH,{}", r.proxy) @@ -125,7 +124,6 @@ fn rule_to_string(r: &Rule) -> String { } } -/// Policy category derived from a rule's target. #[derive(Clone, Copy, PartialEq)] enum Policy { Proxy, @@ -146,7 +144,6 @@ fn policy_of(r: &Rule) -> Policy { } } -/// Type-column color, keyed off the rule's policy (mirrors the mockup). fn type_color(p: Policy) -> u32 { match p { Policy::Match => MUTED3, @@ -156,7 +153,6 @@ fn type_color(p: Policy) -> u32 { } } -/// Policy dot + text color. fn policy_colors(p: Policy) -> (u32, u32) { match p { Policy::Direct => (0x8493A1, SUBTLE), @@ -169,7 +165,7 @@ const COL_TYPE: f32 = 170.; const COL_POLICY: f32 = 150.; impl NyxApp { - pub(crate) fn render_rules(&self, cx: &mut Context) -> impl IntoElement { + pub(crate) fn render_rules(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let rules = st.rules.clone(); let mode = st.mode.clone(); @@ -273,8 +269,7 @@ impl NyxApp { v_flex().size_full().child(header).child(table) } - /// The MRS converter modal: pick a `.mrs` file + behavior, write a decoded ruleset beside it. - pub(crate) fn render_mrs_modal(&self, cx: &mut Context) -> impl IntoElement { + pub(crate) fn render_mrs_modal(&self, cx: &mut Context) -> impl IntoElement + use<> { let input_name = self .mrs_input .as_ref() @@ -388,7 +383,6 @@ impl NyxApp { ) } - /// The smart rule-override editor (opened from the Rules page edit button). pub(crate) fn render_rule_editor( &self, _window: &mut Window, @@ -555,7 +549,6 @@ impl NyxApp { .into_any_element() } - /// A custom (prepend/append) rule row with a remove button. fn rule_custom_row( &self, rule: String, @@ -619,7 +612,6 @@ impl NyxApp { .into_any_element() } - /// A read-only subscription rule row with a delete/restore toggle. fn rule_base_row( &self, r: &Rule, @@ -696,8 +688,7 @@ impl NyxApp { } } -/// A small section header inside the rule editor list. -fn section_label(label: &str, count: usize) -> impl IntoElement { +fn section_label(label: &str, count: usize) -> impl IntoElement + use<> { h_flex() .items_center() .gap_2() diff --git a/src/ui/pages/settings.rs b/src/ui/pages/settings.rs index d7f7e7b..77e635f 100644 --- a/src/ui/pages/settings.rs +++ b/src/ui/pages/settings.rs @@ -1,25 +1,25 @@ -use gpui::prelude::FluentBuilder; use gpui::Entity; +use gpui::prelude::FluentBuilder; use gpui::{ - div, px, rgb, AnyElement, Context, InteractiveElement, IntoElement, ParentElement, - SharedString, StatefulInteractiveElement, Styled, + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::{Input, InputState}; use gpui_component::select::Select; use gpui_component::{ - h_flex, switch::Switch, tooltip::Tooltip, v_flex, Disableable, Icon, IconName, Sizable, - StyledExt, + Disableable, Icon, IconName, Sizable, StyledExt, h_flex, switch::Switch, tooltip::Tooltip, + v_flex, }; use rust_i18n::t; use crate::ui::root::{ - NyxApp, SettingsSub, AMBER, CARD_BG, CARD_BORDER, CONTROL_BG, CONTROL_BORDER, DIVIDER, GREEN, - GREEN_HI, MUTED3, RED, RED_HI, SUBTLE, TEXT, + AMBER, CARD_BG, CARD_BORDER, CONTROL_BG, CONTROL_BORDER, DIVIDER, GREEN, GREEN_HI, MUTED3, + NyxApp, RED, RED_HI, SUBTLE, SettingsSub, TEXT, }; impl NyxApp { - pub(crate) fn render_settings(&self, cx: &mut Context) -> impl IntoElement { + pub(crate) fn render_settings(&self, cx: &mut Context) -> impl IntoElement + use<> { match self.settings_sub { Some(SettingsSub::Tun) => self.render_settings_tun(cx).into_any_element(), Some(SettingsSub::SysProxy) => self.render_settings_sysproxy(cx).into_any_element(), @@ -34,7 +34,7 @@ impl NyxApp { } } - fn render_settings_main(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_main(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let tun = st.tun_enabled; let sysproxy = st.app_flag("sysProxy.enable"); @@ -42,26 +42,36 @@ impl NyxApp { let silent = st.app_flag("silentStart"); let autocheck = st.app_flag("autoCheckUpdate"); - let connectivity = group(vec![ - toggle_row( - t!("pages.settings.tunMode"), - Some(self.gear("gear-tun", SettingsSub::Tun, cx)), - Switch::new("set-tun") - .checked(tun) - .on_click(cx.listener(|this, _, _, cx| this.toggle_tun(cx))), - false, - ), - toggle_row( - t!("pages.settings.systemProxy"), - Some(self.gear("gear-sysproxy", SettingsSub::SysProxy, cx)), - Switch::new("set-sysproxy") - .checked(sysproxy) - .on_click(cx.listener(|_this, checked: &bool, _, cx| { - crate::app::actions::set_sysproxy(*checked, cx) - })), - true, - ), - ]); + let connectivity = v_flex() + .w_full() + .gap(px(6.)) + .child(group(vec![ + toggle_row( + t!("pages.settings.tunMode"), + Some(self.gear("gear-tun", SettingsSub::Tun, cx)), + Switch::new("set-tun") + .checked(tun) + .on_click(cx.listener(|this, _, _, cx| this.toggle_tun(cx))), + false, + ), + toggle_row( + t!("pages.settings.systemProxy"), + Some(self.gear("gear-sysproxy", SettingsSub::SysProxy, cx)), + Switch::new("set-sysproxy") + .checked(sysproxy) + .on_click(cx.listener(|_this, checked: &bool, _, cx| { + crate::app::actions::set_sysproxy(*checked, cx) + })), + true, + ), + ])) + .child( + div() + .px(px(4.)) + .text_xs() + .text_color(rgb(MUTED3)) + .child(t!("pages.settings.connectivityHint").to_string()), + ); let startup = group(vec![ toggle_row( @@ -144,7 +154,6 @@ impl NyxApp { ) } - /// The "Reset application" row — confirms before wiping all app data and relaunching. fn reset_row(&self, cx: &mut Context) -> AnyElement { row_shell(true) .child( @@ -174,7 +183,7 @@ impl NyxApp { .into_any_element() } - fn render_settings_tun(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_tun(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let enabled = st.tun_enabled; let stack = st @@ -223,6 +232,7 @@ impl NyxApp { None, Switch::new("tun-enable") .checked(enabled) + .disabled(!override_on) .on_click(cx.listener(|this, _, _, cx| this.toggle_tun(cx))), false, ), @@ -292,7 +302,6 @@ impl NyxApp { ) } - /// One TUN boolean row that patches `tun.` on toggle. #[allow(clippy::too_many_arguments)] fn tun_toggle( &self, @@ -317,7 +326,7 @@ impl NyxApp { ) } - fn render_settings_sysproxy(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_sysproxy(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let enable = st.app_flag("sysProxy.enable"); let affect_vpn = st.app_flag("affectVPNConnections"); @@ -414,7 +423,7 @@ impl NyxApp { ) } - fn render_settings_dns(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_dns(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let enhanced = st .ctl("dns.enhanced-mode") @@ -547,7 +556,6 @@ impl NyxApp { ) } - /// One DNS boolean row that patches `dns.` on toggle. #[allow(clippy::too_many_arguments)] fn dns_toggle( &self, @@ -572,7 +580,7 @@ impl NyxApp { ) } - fn render_settings_mihomo(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_mihomo(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let allow_lan = st.ctl_bool("allow-lan", false); let ipv6 = st.ctl_bool("ipv6", false); @@ -605,8 +613,7 @@ impl NyxApp { let body = settings_body() .child(self.mihomo_core_card(&core, cx)) - .when(cfg!(windows), |b| b.child(self.mihomo_service_card(cx))) - .when(!cfg!(windows), |b| b.child(self.tun_permission_card(cx))) + .child(self.mihomo_service_card(cx)) .child(group(vec![ input_row( t!("pages.settings.mixedPort"), @@ -786,7 +793,6 @@ impl NyxApp { ) } - /// Core version + channel (stable/prerelease) + update button. fn mihomo_core_card(&self, core: &str, cx: &mut Context) -> AnyElement { let busy = self.service_busy; let installed = if self.core_version_installed.is_empty() { @@ -864,13 +870,13 @@ impl NyxApp { .into_any_element() } - /// Windows service status + install/start/stop/restart/uninstall controls. fn mihomo_service_card(&self, cx: &mut Context) -> AnyElement { let busy = self.service_busy; let status = self.service_status.to_string(); let (label, color) = match status.as_str() { "running" => (t!("pages.settings.svcRunning"), GREEN_HI), "stopped" => (t!("pages.settings.svcStopped"), AMBER), + "stale" => (t!("pages.settings.svcStale"), AMBER), "not-installed" => (t!("pages.settings.svcNotInstalled"), MUTED3), "" => (t!("pages.settings.svcChecking"), MUTED3), _ => (t!("pages.settings.svcUnknown"), MUTED3), @@ -882,8 +888,14 @@ impl NyxApp { b.on_click(cx.listener(move |this, _, _, cx| this.service_action(action, cx))) }; + let stage = if self.service_managed { + "managed" + } else { + status.as_str() + }; + let mut actions = h_flex().gap_2().flex_wrap(); - match status.as_str() { + match stage { "running" => { actions = actions .child(svc_btn( @@ -920,7 +932,15 @@ impl NyxApp { true, )); } - "" => {} + "managed" | "" => {} + "stale" => { + actions = actions.child(svc_btn( + "svc-repair", + "install", + t!("pages.settings.svcRepair").to_string(), + false, + )); + } _ => { actions = actions.child(svc_btn( "svc-install", @@ -956,78 +976,26 @@ impl NyxApp { ), ), ) - .child(actions) - .into_any_element() - } - - /// Non-Windows service-card replacement: TUN capability status + (Linux) a grant button. - fn tun_permission_card(&self, cx: &mut Context) -> AnyElement { - let granted = tun_granted(); - let nixos = tun_is_nixos(); - let (label, color) = if granted { - (t!("pages.settings.tunGranted"), GREEN_HI) - } else { - (t!("pages.settings.tunNotGranted"), AMBER) - }; - let hint = if cfg!(target_os = "macos") { - t!("pages.settings.tunHintMac") - } else if nixos { - t!("pages.settings.tunHintNixos") - } else { - t!("pages.settings.tunHint") - }; - - let card = settings_card(t!("pages.settings.tunSection")) - .child( - h_flex() - .items_center() - .justify_between() - .py(px(2.)) - .child( - div() - .text_sm() - .text_color(rgb(TEXT)) - .child(t!("pages.settings.tunStatus").to_string()), - ) - .child( - h_flex() - .gap_2() - .items_center() - .child(div().size(px(7.)).rounded_full().bg(rgb(color))) - .child( - div() - .text_sm() - .text_color(rgb(color)) - .child(label.to_string()), - ), - ), - ) + .children(self.service_detail.clone().map(|reason| { + div() + .text_xs() + .text_color(rgb(MUTED3)) + .child(reason.to_string()) + })) .child( div() .text_xs() .text_color(rgb(MUTED3)) - .child(hint.to_string()), - ); - - #[cfg(target_os = "linux")] - let card = card.when(!granted && !nixos, |c| { - c.child( - h_flex().child( - Button::new("tun-grant") - .small() - .primary() - .label(t!("pages.settings.tunGrant").to_string()) - .on_click(cx.listener(|this, _, _, cx| this.grant_tun(cx))), - ), + .child(if self.service_managed { + t!("pages.settings.svcManagedHint").to_string() + } else { + t!("pages.settings.svcHint").to_string() + }), ) - }); - #[cfg(not(target_os = "linux"))] - let _ = cx; - - card.into_any_element() + .child(actions) + .into_any_element() } - /// One top-level mihomo boolean row that patches `` and restarts core. fn core_toggle( &self, id: &'static str, @@ -1049,7 +1017,6 @@ impl NyxApp { ) } - /// A boolean row under the config's `profile` map (`store-selected` / `store-fake-ip`). fn profile_toggle( &self, id: &'static str, @@ -1071,7 +1038,6 @@ impl NyxApp { ) } - /// A titled card of choice pills that patch `key` to the picked value (empty clears). fn core_choice_card( &self, key: &'static str, @@ -1125,7 +1091,7 @@ impl NyxApp { .into_any_element() } - fn render_settings_sniffer(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_sniffer(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let override_dest = st.ctl_bool("sniffer.override-destination", false); let force_dns = st.ctl_bool("sniffer.force-dns-mapping", true); @@ -1224,7 +1190,6 @@ impl NyxApp { ) } - /// One sniffer boolean row (patches `sniffer.` + restarts core). #[allow(clippy::too_many_arguments)] fn sniffer_toggle( &self, @@ -1249,7 +1214,7 @@ impl NyxApp { ) } - /// Per-protocol sniff toggle: sets/clears `sniffer.sniff..ports`. + /// Per-protocol sniff toggle: sets or clears `sniffer.sniff..ports`. #[allow(clippy::too_many_arguments)] fn sniff_proto_toggle( &self, @@ -1279,7 +1244,7 @@ impl NyxApp { ) } - fn render_settings_resources(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_resources(&self, cx: &mut Context) -> impl IntoElement + use<> { let busy = self.resources_busy; let geo_btn = Button::new("res-geo") .primary() @@ -1310,7 +1275,6 @@ impl NyxApp { ) } - /// A card listing providers (per-row view + update) with an "Update all" header button. fn providers_card( &self, title: String, @@ -1430,12 +1394,15 @@ impl NyxApp { card.into_any_element() } - fn render_settings_appearance(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_appearance(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let on_top = st.app_flag("alwaysOnTop"); let disable_tray = st.app_flag("disableTray"); + let system_frame = st.app_flag("useWindowFrame"); - let body = settings_body().child(group(vec![ + let tray_last = !cfg!(target_os = "linux"); + #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] + let mut rows = vec![ self.flag_toggle( "ap-ontop", t!("pages.settings.alwaysOnTop"), @@ -1449,10 +1416,26 @@ impl NyxApp { t!("pages.settings.disableTray"), "disableTray", disable_tray, - false, + tray_last, cx, ), - ])); + ]; + // Only Linux can swap between our title bar and the compositor's. + #[cfg(target_os = "linux")] + rows.push(toggle_row( + t!("pages.settings.systemWindowFrame"), + None, + Switch::new("ap-frame") + .checked(system_frame) + .on_click(cx.listener(move |this, checked: &bool, window, cx| { + crate::app::window::request_decorations(window, *checked); + this.set_app_flag(serde_json::json!({ "useWindowFrame": *checked }), cx); + })), + true, + )); + #[cfg(not(target_os = "linux"))] + let _ = system_frame; + let body = settings_body().child(group(rows)); self.sub_scroll( t!("pages.settings.appearance").to_string(), false, @@ -1462,7 +1445,7 @@ impl NyxApp { ) } - fn render_settings_advanced(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_advanced(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let stop_on_disconnect = st.app_flag("stopCoreOnDisconnect"); let net_detect = st.app_flag("networkDetection"); @@ -1501,7 +1484,7 @@ impl NyxApp { ) } - fn render_settings_shortcuts(&self, cx: &mut Context) -> impl IntoElement { + fn render_settings_shortcuts(&self, cx: &mut Context) -> impl IntoElement + use<> { let st = self.state.read(cx); let cfg = st.app_config.clone(); let read = |key: &str| -> String { @@ -1535,6 +1518,10 @@ impl NyxApp { t!("pages.settings.scQuitKeepCore").into(), ), ]; + if !crate::app::hotkeys::supported() { + return self.render_shortcuts_unavailable(&rows, cx); + } + let n = rows.len(); let card = group( rows.iter() @@ -1552,7 +1539,6 @@ impl NyxApp { .text_color(rgb(MUTED3)) .child(t!("pages.settings.scHint").to_string()); - // Key-capture surface: rows focus this, the next keystroke is recorded. let body = div() .track_focus(&self.recorder_focus) .child(settings_body().child(card).child(hint)) @@ -1568,7 +1554,74 @@ impl NyxApp { ) } - /// One shortcut row: click to record, shows "Press keys…" while recording. + /// Wayland offers no global-grab protocol, so instead of dead recorder rows + /// we list the `nyx://` commands to bind in the compositor's own config. + fn render_shortcuts_unavailable( + &self, + rows: &[(&'static str, SharedString)], + cx: &mut Context, + ) -> AnyElement { + let link = |key: &str| match key { + "showWindowShortcut" => "nyx://toggle-window", + "ruleModeShortcut" => "nyx://mode?value=rule", + "globalModeShortcut" => "nyx://mode?value=global", + "triggerTunShortcut" => "nyx://toggle-tun", + "triggerSysProxyShortcut" => "nyx://toggle-sysproxy", + "restartAppShortcut" => "nyx://restart", + _ => "nyx://quit", + }; + let n = rows.len(); + let card = group( + rows.iter() + .enumerate() + .map(|(i, (key, label))| { + row_shell(i + 1 == n) + .child(div().text_sm().text_color(rgb(TEXT)).child(label.clone())) + .child( + div() + .h(px(28.)) + .px(px(12.)) + .flex() + .items_center() + .rounded(px(7.)) + .bg(rgb(CONTROL_BG)) + .border_1() + .border_color(rgb(CONTROL_BORDER)) + .text_xs() + .text_color(rgb(SUBTLE)) + .child(link(key)), + ) + .into_any_element() + }) + .collect(), + ); + let body = settings_body() + .child( + div() + .px(px(24.)) + .pb(px(8.)) + .text_sm() + .text_color(rgb(TEXT)) + .child(t!("pages.settings.scWaylandTitle").to_string()), + ) + .child(card) + .child( + div() + .px(px(24.)) + .pb(px(8.)) + .text_xs() + .text_color(rgb(MUTED3)) + .child(t!("pages.settings.scWaylandHint").to_string()), + ); + self.sub_scroll( + t!("pages.settings.shortcuts").to_string(), + false, + None, + body, + cx, + ) + } + fn shortcut_row( &self, key: &'static str, @@ -1612,7 +1665,6 @@ impl NyxApp { .into_any_element() } - /// One app-config boolean row (patches the flat `` on toggle). fn flag_toggle( &self, id: &'static str, @@ -1634,8 +1686,7 @@ impl NyxApp { ) } - /// The "override subscription" toggle + hint atop the DNS/Sniffer/TUN pages; - /// `key` is the gating flag (`controlDns`/`controlSniff`/`controlTun`). + /// The "override subscription" toggle gating a DNS/Sniffer/TUN page. fn override_group( &self, id: &'static str, @@ -1664,7 +1715,6 @@ impl NyxApp { .into_any_element() } - /// A clickable gear icon that opens a Settings sub-page. fn gear(&self, id: &'static str, sub: SettingsSub, cx: &mut Context) -> AnyElement { div() .id(id) @@ -1680,7 +1730,6 @@ impl NyxApp { .into_any_element() } - /// A section row that navigates into a sub-page on click. fn nav_sub_row( &self, label: impl Into, @@ -1710,7 +1759,7 @@ impl NyxApp { .into_any_element() } - /// Wraps a sub-page body with a back header plus optional Save + action elements. + /// Wraps a sub-page body with a back header plus optional Save + actions. fn sub_scroll( &self, title: String, @@ -1788,7 +1837,6 @@ impl NyxApp { .into_any_element() } - /// The "Check for updates" row; shows a checking state while a GitHub check runs. fn check_update_row(&self, cx: &mut Context) -> AnyElement { let checking = self.update_checking; let button_label = if checking { @@ -1823,7 +1871,6 @@ impl NyxApp { .into_any_element() } - /// The language selector row (a dropdown over the language list). fn language_row(&self) -> AnyElement { let control = div() .w(px(160.)) @@ -1832,7 +1879,6 @@ impl NyxApp { } } -/// Header + scroll container for a settings list. fn settings_scroll(title: String) -> gpui::Stateful { v_flex() .size_full() @@ -1849,12 +1895,10 @@ fn settings_scroll(title: String) -> gpui::Stateful { ) } -/// The padded column that holds the setting cards (full width to the right edge). fn settings_body() -> gpui::Div { v_flex().w_full().px(px(24.)).pb(px(22.)).gap(px(14.)) } -/// A rounded group card; children are the (already divider-bordered) rows. fn group(rows: Vec) -> impl IntoElement { v_flex() .w_full() @@ -1866,7 +1910,7 @@ fn group(rows: Vec) -> impl IntoElement { .children(rows) } -/// Base row: label-left / control-right, with an optional bottom divider. +/// Base row: label left, control right, optional bottom divider. fn row_shell(last: bool) -> gpui::Div { h_flex() .items_center() @@ -1876,7 +1920,6 @@ fn row_shell(last: bool) -> gpui::Div { .when(!last, |this| this.border_b_1().border_color(rgb(DIVIDER))) } -/// A row with a plain text label and an arbitrary control on the right. fn control_row(label: impl Into, control: AnyElement, last: bool) -> AnyElement { row_shell(last) .child(div().text_sm().text_color(rgb(TEXT)).child(label.into())) @@ -1902,8 +1945,7 @@ fn toggle_row( .into_any_element() } -/// A row with a label and a text input on the right (empty if no input yet). -/// `enabled` greys out the input when an override toggle gates the page. +/// Label + text input; `enabled` greys the input out when an override gates the page. fn input_row( label: impl Into, input: Option<&Entity>, @@ -1920,8 +1962,7 @@ fn input_row( control_row(label, control, last) } -/// A full-width DNS list card: a label header above a multi-line text input -/// (one server / entry per line). `enabled` greys out the input. +/// Multi-line list card (one entry per line); `enabled` greys the input out. fn dns_list_card( label: impl Into, input: Option<&Entity>, @@ -1953,34 +1994,7 @@ fn dns_list_card( .into_any_element() } -/// Whether the running process can give the core TUN access (Linux: holds -/// `CAP_NET_ADMIN`; elsewhere: running as root). -fn tun_granted() -> bool { - #[cfg(target_os = "linux")] - { - crate::backend::elevation::has_net_admin() - } - #[cfg(not(target_os = "linux"))] - { - crate::backend::elevation::is_elevated() - } -} - -/// On NixOS, TUN caps come from `programs.nyx.tunMode` (declarative wrapper), -/// not a runtime `setcap` — so we swap the grant button for instructions. -fn tun_is_nixos() -> bool { - #[cfg(target_os = "linux")] - { - crate::backend::elevation::is_nixos() - } - #[cfg(not(target_os = "linux"))] - { - false - } -} - -/// True on Linux desktops where the system proxy only reaches some apps (not -/// GNOME-like); used to warn that TUN is the reliable full-coverage option. +/// True where the system proxy reaches only some apps, so TUN is the reliable option. fn sysproxy_partial() -> bool { #[cfg(target_os = "linux")] { @@ -2024,7 +2038,6 @@ fn settings_card(title: impl Into) -> gpui::Div { ) } -/// A `label : value` info row (read-only). fn kv_text(label: impl Into, value: String) -> AnyElement { h_flex() .items_center() @@ -2042,7 +2055,6 @@ fn kv_text(label: impl Into, value: String) -> AnyElement { .into_any_element() } -/// The current installed app version, shown beneath the "check for updates" row. fn version_row() -> AnyElement { row_shell(true) .child( diff --git a/src/ui/rail.rs b/src/ui/rail.rs index 9b94488..237065c 100644 --- a/src/ui/rail.rs +++ b/src/ui/rail.rs @@ -1,21 +1,20 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, img, px, rgb, rgba, Context, InteractiveElement, IntoElement, ParentElement, SharedString, - StatefulInteractiveElement, Styled, + Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, img, px, rgb, rgba, }; -use gpui_component::{h_flex, v_flex, Icon, IconName, StyledExt}; +use gpui_component::{Icon, IconName, StyledExt, h_flex, v_flex}; use rust_i18n::t; use crate::ui::root::{NyxApp, Route}; use crate::ui::theme::*; -/// Off-state icon tint used throughout the rail (design `#74879a`). const RAIL_ICON: u32 = 0x74879A; const RAIL_W_COLLAPSED: f32 = 56.; const RAIL_W_EXPANDED: f32 = 208.; impl NyxApp { - pub(crate) fn render_rail(&self, cx: &mut Context) -> impl IntoElement { + pub(crate) fn render_rail(&self, cx: &mut Context) -> impl IntoElement + use<> { let expanded = self.rail_expanded; let mode = self.state.read(cx).mode.clone(); // No profile yet: keep only Home, Profiles, Settings; hide proxy pages. @@ -142,8 +141,7 @@ impl NyxApp { .child(bottom) } - /// The app logo (and wordmark, when expanded) in a dark rounded tile. - fn brand_mark(&self) -> impl IntoElement { + fn brand_mark(&self) -> impl IntoElement + use<> { let logo = img("brand/logo.png").size(px(28.)).rounded(px(7.)); if self.rail_expanded { h_flex() @@ -166,7 +164,6 @@ impl NyxApp { } } - /// A primary destination (active when it matches the current route). fn rail_nav( &self, key: &str, @@ -199,7 +196,6 @@ impl NyxApp { .into_any_element() } - /// A bottom action button (mode toggle / sidebar toggle). fn rail_action( &self, key: &str, @@ -216,8 +212,7 @@ impl NyxApp { .into_any_element() } - /// Shared rail cell: icon-only when collapsed, icon+label when expanded; - /// active state is a faint green wash. + /// Shared rail cell: icon-only when collapsed, icon+label when expanded. fn rail_cell(&self, icon: Icon, label: impl Into, on: bool) -> impl IntoElement { let color = if on { GREEN_HI } else { RAIL_ICON }; let glyph = icon.size(px(19.)).text_color(rgb(color)); diff --git a/src/ui/root.rs b/src/ui/root.rs index 3db9b37..4e4e2d3 100644 --- a/src/ui/root.rs +++ b/src/ui/root.rs @@ -1,29 +1,28 @@ use gpui::prelude::FluentBuilder; use gpui::{ - div, px, rgb, rgba, size, App, AppContext, Context, Entity, InteractiveElement, IntoElement, - ParentElement, PathPromptOptions, Render, ScrollHandle, StatefulInteractiveElement, Styled, - Subscription, Window, WindowBounds, WindowOptions, + App, AppContext, Context, Decorations, Entity, InteractiveElement, IntoElement, ParentElement, + PathPromptOptions, Render, ScrollHandle, StatefulInteractiveElement, Styled, Subscription, + Window, WindowBounds, WindowOptions, div, px, rgb, rgba, size, }; +use gpui_component::IndexPath; use gpui_component::input::{Input, InputState}; use gpui_component::select::{SelectEvent, SelectState}; -use gpui_component::IndexPath; use gpui_component::{ + Disableable, Root, StyledExt, TitleBar, button::{Button, ButtonVariants}, h_flex, text::TextView, - v_flex, Disableable, Root, StyledExt, TitleBar, + v_flex, window_border, }; use rust_i18n::t; use crate::app::runtime; -use crate::app::state::{parse_groups, AppState}; +use crate::app::state::{AppState, parse_groups}; use crate::backend; -// Nyx palette + gradients live in `ui::theme`; re-export so pages keep -// importing color tokens from `crate::ui::root::*`. +// Color tokens live in `ui::theme`; re-exported so pages import them from here. pub(crate) use crate::ui::theme::*; -/// Top-level navigation targets. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Route { Home, @@ -35,7 +34,6 @@ pub(crate) enum Route { Settings, } -/// Log-level filter for the Logs page segmented control. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum LogFilter { All, @@ -44,7 +42,6 @@ pub(crate) enum LogFilter { Error, } -/// Settings detail sub-pages opened from the gear icons / section rows. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum SettingsSub { Tun, @@ -58,20 +55,17 @@ pub(crate) enum SettingsSub { Shortcuts, } -/// A rule/proxy provider row on the Resources page. #[derive(Clone)] pub(crate) struct ProviderRow { pub(crate) name: gpui::SharedString, pub(crate) subtitle: gpui::SharedString, } -/// The Resources page's provider-content viewer (a read-only code editor modal). pub(crate) struct ProviderViewerState { pub(crate) title: String, pub(crate) editor: Entity, } -/// Text inputs owned by the active Settings sub-page (created on open). #[derive(Default)] pub(crate) struct SubInputs { pub(crate) device: Option>, @@ -102,15 +96,13 @@ pub(crate) struct SubInputs { pub(crate) sniff_skip_src: Option>, } -/// What the embedded YAML editor is currently editing. #[derive(Clone)] pub(crate) enum EditorTarget { Profile { id: String, name: String }, RuntimeReadonly, } -/// State of the smart rule-override editor: `prepend`/`append` custom rules plus -/// the subscription's read-only rules. +/// Smart rule-override editor: custom prepend/append plus the read-only subscription rules. pub(crate) struct RuleEditState { pub(crate) profile_id: String, pub(crate) profile_name: String, @@ -118,8 +110,7 @@ pub(crate) struct RuleEditState { pub(crate) append: Vec, /// Subscription rule strings the user has chosen to drop (override `delete`). pub(crate) delete: Vec, - /// "Add rule" form: type, payload, target policy (a dropdown of live groups/ - /// nodes + DIRECT/REJECT/…), and where to insert. + /// "Add rule" form: type, payload, target policy, insert position. pub(crate) type_select: Entity>>, pub(crate) payload: Entity, pub(crate) policy_select: Entity>>, @@ -128,53 +119,42 @@ pub(crate) struct RuleEditState { _type_sub: Subscription, } -/// Root view: custom title bar + sidebar + routed content. pub(crate) struct NyxApp { pub(crate) state: Entity, pub(crate) route: Route, /// First-run welcome flow: `Some(step)` while active (0..=3), `None` once done. pub(crate) onboarding_step: Option, pub(crate) rail_expanded: bool, - /// Currently focused proxy group on the Proxies page (right-hand node grid). pub(crate) proxies_group: Option, - /// Proxies page node-grid controls: search, sort-by-latency, alive-only. pub(crate) proxies_search: Entity, pub(crate) proxies_sort_latency: bool, pub(crate) proxies_alive_only: bool, pub(crate) logs_filter: LogFilter, - /// Connections page: process-name filter + the process whose detail is open. pub(crate) conns_filter: Entity, pub(crate) conns_detail: Option, /// Connections page tab: `false` = active, `true` = recently closed. pub(crate) conns_show_closed: bool, - /// A single connection selected for the detail popup (within a process). pub(crate) conn_detail_item: Option, - /// Scroll handle for the Logs console (used to stick to the bottom). pub(crate) logs_scroll: ScrollHandle, /// Total log count last rendered — autoscroll fires when it grows. pub(crate) logs_seen: std::cell::Cell, - /// Active Settings sub-page, if any (gear / section navigation). pub(crate) settings_sub: Option, pub(crate) sub_inputs: SubInputs, - /// Shortcuts page: the app-config key currently being recorded, if any. pub(crate) recording_shortcut: Option<&'static str>, pub(crate) recorder_focus: gpui::FocusHandle, - /// Mihomo settings: Windows service status + installed core version, plus a busy guard. pub(crate) service_status: gpui::SharedString, + pub(crate) service_detail: Option, + pub(crate) service_managed: bool, pub(crate) core_version_installed: gpui::SharedString, pub(crate) service_busy: bool, - /// Resources page: fetched providers + an in-flight guard for geo/provider updates. pub(crate) proxy_providers: Vec, pub(crate) rule_providers: Vec, pub(crate) resources_busy: bool, - /// Open provider-content viewer modal (Resources page), if any. pub(crate) provider_viewer: Option, pub(crate) editor: Option>, pub(crate) editor_target: Option, - /// Active smart rule editor, if open (Rules page). pub(crate) rule_editor: Option, pub(crate) import_url: Entity, - /// "Add profile" modal: open flag, remote/local toggle, name, picked local file. pub(crate) profile_add_open: bool, pub(crate) profile_add_local: bool, pub(crate) profile_add_name: Entity, @@ -183,24 +163,18 @@ pub(crate) struct NyxApp { pub(crate) profile_add_file: Option<(String, String)>, /// Id of the profile being edited; `None` when the modal is creating a new one. pub(crate) profile_edit_id: Option, - /// Set while a profile import is downloading; keeps the modal open + disabled. pub(crate) profile_add_busy: bool, - /// Last import error, shown inline in the modal. pub(crate) profile_add_error: Option, - /// MRS converter modal: open flag, input file, mihomo behavior. pub(crate) mrs_open: bool, pub(crate) mrs_input: Option, pub(crate) mrs_behavior: &'static str, pub(crate) connected_since: Option, pub(crate) stats_open: bool, - /// Settings language picker (a real dropdown over [`LANGUAGES`]). pub(crate) lang_select: Entity>>, - /// Auto-updater: pending newer release + in-flight flags + modal open. pub(crate) update_info: Option, pub(crate) update_checking: bool, pub(crate) update_installing: bool, pub(crate) updater_open: bool, - /// Whether the "reset application" confirmation dialog is open. pub(crate) reset_confirm_open: bool, /// Guards the one-time silent auto-check after config loads. auto_update_checked: bool, @@ -210,7 +184,6 @@ pub(crate) struct NyxApp { _proxies_search_sub: Subscription, } -/// Human-readable byte count (e.g. `1.2 MB`). pub(crate) fn fmt_bytes(n: u64) -> String { if n == 0 { return "0 B".to_string(); @@ -229,13 +202,11 @@ pub(crate) fn fmt_bytes(n: u64) -> String { } } -/// Human-readable transfer rate (e.g. `1.2 MB/s`). pub(crate) fn fmt_speed(n: u64) -> String { format!("{}/s", fmt_bytes(n)) } -/// Parses a `/providers/{proxies,rules}` response into displayable rows, -/// skipping built-in `Compatible` providers (which can't be updated). +/// Parses a providers response into rows, skipping built-in `Compatible` ones. fn parse_providers(value: &serde_json::Value, is_rule: bool) -> Vec { let Some(obj) = value.get("providers").and_then(|v| v.as_object()) else { return Vec::new(); @@ -286,7 +257,6 @@ impl NyxApp { let profile_add_name = cx.new(|cx| InputState::new(window, cx).placeholder("Name")); let profile_interval = cx.new(|cx| InputState::new(window, cx).placeholder("0")); let conns_filter = cx.new(|cx| InputState::new(window, cx)); - // Re-render the connections list as the user types in the filter box. let conns_filter_sub = cx.subscribe( &conns_filter, |_this, _input, _event: &gpui_component::input::InputEvent, cx| cx.notify(), @@ -299,15 +269,17 @@ impl NyxApp { &proxies_search, |_this, _input, _event: &gpui_component::input::InputEvent, cx| cx.notify(), ); - // Re-render on shared-state change; track TUN up-time for Home's timer. + // Re-render on shared-state change; track uptime for Home's timer. let sub = cx.observe(&state, |this: &mut Self, observed, cx| { - let connected = observed.read(cx).tun_enabled; + let connected = { + let st = observed.read(cx); + st.tun_enabled || st.app_flag("sysProxy.enable") + }; if connected && this.connected_since.is_none() { this.connected_since = Some(std::time::Instant::now()); } else if !connected { this.connected_since = None; } - // One-time silent update check once config loads, if auto-check is on. if !this.auto_update_checked && !observed.read(cx).app_config.is_null() { this.auto_update_checked = true; if observed.read(cx).app_flag("autoCheckUpdate") { @@ -317,7 +289,6 @@ impl NyxApp { cx.notify(); }); - // Language dropdown over LANGUAGES, preselected to the active locale. use crate::app::state::LANGUAGES; let current_lang = state.read(cx).language.clone(); let names: Vec = @@ -354,6 +325,8 @@ impl NyxApp { recording_shortcut: None, recorder_focus: cx.focus_handle(), service_status: gpui::SharedString::default(), + service_detail: None, + service_managed: backend::core::service_managed(), core_version_installed: gpui::SharedString::default(), service_busy: false, proxy_providers: Vec::new(), @@ -391,8 +364,7 @@ impl NyxApp { } } - /// Checks GitHub for a newer release and opens the updater modal if one - /// exists. When not `silent`, also toasts the outcome. + /// Checks GitHub for a newer release; when not `silent`, also toasts the outcome. pub(crate) fn check_update(&mut self, silent: bool, cx: &mut Context) { if self.update_checking || self.update_installing { return; @@ -431,7 +403,6 @@ impl NyxApp { .detach(); } - /// Downloads + installs the pending update, then relaunches. pub(crate) fn install_update(&mut self, cx: &mut Context) { if self.update_installing { return; @@ -444,8 +415,7 @@ impl NyxApp { Err(_) => Err("update task was cancelled".to_string()), }; match outcome { - // `true` → an external helper swaps the binary and relaunches - // (Windows); leave this process for it to replace. + // `true` → an external helper swaps the binary and relaunches (Windows). Ok(true) => {} Ok(false) => { cx.update(crate::app::actions::restart_app); @@ -468,25 +438,21 @@ impl NyxApp { .detach(); } - /// Closes the updater modal (keeps the pending info for a later open). pub(crate) fn close_updater(&mut self, cx: &mut Context) { self.updater_open = false; cx.notify(); } - /// Opens the "reset application" confirmation dialog. pub(crate) fn open_reset_confirm(&mut self, cx: &mut Context) { self.reset_confirm_open = true; cx.notify(); } - /// Dismisses the reset confirmation dialog without resetting. pub(crate) fn close_reset_confirm(&mut self, cx: &mut Context) { self.reset_confirm_open = false; cx.notify(); } - /// Wipes all app data then relaunches the app (confirmed reset). pub(crate) fn confirm_reset(&mut self, cx: &mut Context) { self.reset_confirm_open = false; cx.notify(); @@ -497,7 +463,6 @@ impl NyxApp { .detach(); } - /// Applies a language picked in the Settings dropdown. fn on_language_selected( &mut self, _select: Entity>>, @@ -517,46 +482,17 @@ impl NyxApp { impl NyxApp { pub(crate) fn toggle_tun(&mut self, cx: &mut Context) { - let new = !self.state.read(cx).tun_enabled; - let running = self.state.read(cx).core_status.is_running(); - self.state.update(cx, |st, c| st.set_tun_enabled(new, c)); - crate::app::tray::rebuild(cx); - cx.spawn(async move |_this, cx| { - if !running && !crate::app::bootstrap::start_core_and_streams(cx).await { - cx.update(|cx| { - AppState::global(cx).update(cx, |st, c| st.set_tun_enabled(false, c)); - crate::app::tray::rebuild(cx); - }); - return; - } - let patch = if new { - serde_json::json!({ "tun": { "enable": true }, "dns": { "enable": true } }) - } else { - serde_json::json!({ "tun": { "enable": false } }) - }; - let _ = runtime::spawn(backend::config::patch_controled_mihomo_config(patch)).await; - let _ = runtime::spawn(backend::config::patch_app_config( - serde_json::json!({ "lastConnected": new }), - )) - .await; - if let Ok(Ok(cfg)) = - runtime::spawn(backend::config::get_controled_mihomo_config()).await - { - let tun = cfg - .get("tun") - .and_then(|t| t.get("enable")) - .and_then(|v| v.as_bool()) - .unwrap_or(new); - cx.update(|cx| { - AppState::global(cx).update(cx, |st, c| st.set_tun_enabled(tun, c)); - crate::app::tray::rebuild(cx); - }); - } - }) - .detach(); + crate::app::actions::toggle_tun(cx); + } + + pub(crate) fn toggle_connection(&mut self, cx: &mut Context) { + crate::app::actions::toggle_connection(cx); + } + + pub(crate) fn select_connection_mode(&mut self, mode: &'static str, cx: &mut Context) { + crate::app::actions::select_connection_mode(mode, cx); } - /// Selects `proxy` within `group`, then refreshes the group list. pub(crate) fn change_proxy(&mut self, group: String, proxy: String, cx: &mut Context) { self.state.update(cx, |st, c| { if let Some(g) = st.groups.iter_mut().find(|g| g.name.as_ref() == group) { @@ -574,7 +510,6 @@ impl NyxApp { .detach(); } - /// Latency-tests a single proxy and stores the result on its node. pub(crate) fn test_proxy_delay( &mut self, group: String, @@ -601,7 +536,6 @@ impl NyxApp { .detach(); } - /// Latency-tests an entire group, updating every member's delay. pub(crate) fn test_group_delay(&mut self, group: String, cx: &mut Context) { cx.spawn(async move |_this, cx| { let g = group.clone(); @@ -630,7 +564,6 @@ impl NyxApp { .detach(); } - /// Manually re-fetches the proxy group list. pub(crate) fn refresh_proxies(&mut self, cx: &mut Context) { cx.spawn(async move |_this, cx| refresh_groups(cx).await) .detach(); @@ -679,7 +612,6 @@ impl NyxApp { .detach(); } - /// Closes and re-establishes all active connections through the current rules. pub(crate) fn restart_connections(&mut self, cx: &mut Context) { cx.spawn(async move |_this, _cx| { let _ = runtime::spawn(backend::api::restart_connections()).await; @@ -769,7 +701,6 @@ impl NyxApp { } } -/// Fetches groups on the tokio runtime and folds them into `AppState`. async fn refresh_groups(cx: &mut gpui::AsyncApp) { if let Ok(Ok(val)) = runtime::spawn(backend::mihomo::groups()).await { cx.update(|cx| { @@ -780,7 +711,11 @@ async fn refresh_groups(cx: &mut gpui::AsyncApp) { } impl NyxApp { - fn render_content(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render_content( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { let has_profiles = !self.state.read(cx).profiles.is_empty(); let route = if !has_profiles && !matches!(self.route, Route::Home | Route::Profiles | Route::Settings) @@ -813,7 +748,7 @@ impl NyxApp { } #[allow(dead_code)] - fn render_placeholder(&self, route: Route) -> impl IntoElement { + fn render_placeholder(&self, route: Route) -> impl IntoElement + use<> { let title = match route { Route::Home => t!("sider.home"), Route::Profiles => t!("sider.profileManagement"), @@ -838,8 +773,7 @@ impl NyxApp { } impl NyxApp { - /// The auto-updater modal (version, changelog, Later/Update). Rendered while `updater_open`. - fn render_updater_modal(&self, cx: &mut Context) -> impl IntoElement { + fn render_updater_modal(&self, cx: &mut Context) -> impl IntoElement + use<> { let (version, changelog) = self .update_info .clone() @@ -914,8 +848,7 @@ impl NyxApp { } impl NyxApp { - /// The "reset application" confirmation dialog. Rendered while `reset_confirm_open`. - fn render_reset_confirm(&self, cx: &mut Context) -> impl IntoElement { + fn render_reset_confirm(&self, cx: &mut Context) -> impl IntoElement + use<> { div() .id("reset-scrim") .absolute() @@ -980,8 +913,7 @@ impl NyxApp { } impl NyxApp { - /// The Resources provider-content viewer: a read-only editor of the picked provider. - fn render_provider_viewer(&self, cx: &mut Context) -> impl IntoElement { + fn render_provider_viewer(&self, cx: &mut Context) -> impl IntoElement + use<> { let Some(viewer) = self.provider_viewer.as_ref() else { return div().into_any_element(); }; @@ -1052,8 +984,7 @@ impl NyxApp { impl Render for NyxApp { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // The top-level view must render `Root`'s overlay layers itself, or - // toasts/modals never appear. + // The top-level view must render `Root`'s overlay layers, or toasts never appear. let dialog_layer = Root::render_dialog_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx); let updater_modal = self.updater_open.then(|| self.render_updater_modal(cx)); @@ -1070,56 +1001,62 @@ impl Render for NyxApp { let mrs_modal = self.mrs_open.then(|| self.render_mrs_modal(cx)); let onboarding = self.onboarding_active().then(|| self.render_onboarding(cx)); - v_flex() - .size_full() - .bg(rgb(TITLEBAR_BG)) - .child({ - let title_bar = TitleBar::new(); - // The X defaults to remove_window(); on Linux route it through our - // close logic so it saves bounds and Ctrl+X disconnects + quits. - // QuitMode::Explicit keeps the app in the tray after the close. - #[cfg(not(windows))] - let title_bar = title_bar.on_close_window(|_, window, cx| { - save_main_window_bounds(window); - if window.modifiers().control { - crate::app::actions::disconnect_and_quit(cx); - } else { - window.remove_window(); - } - }); - title_bar.child( + let title_bar = (!cfg!(target_os = "linux") + || matches!(window.window_decorations(), Decorations::Client { .. })) + .then(|| self.render_title_bar()); + + window_border().child( + v_flex() + .size_full() + .bg(rgb(TITLEBAR_BG)) + .children(title_bar) + .child( h_flex() - .w_full() - .pl_2() - .items_center() - .text_color(rgb(SUBTLE)) - .text_size(px(12.5)) - .font_semibold() - .child("Nyx"), + .flex_1() + .min_h_0() + .bg(content_bg()) + .child(self.render_rail(cx)) + .child(self.render_content(window, cx)), ) - }) - .child( - h_flex() - .flex_1() - .min_h_0() - .bg(content_bg()) - .child(self.render_rail(cx)) - .child(self.render_content(window, cx)), - ) - // Onboarding card sits below the modals so dialogs open above it. - .children(onboarding) - .children(updater_modal) - .children(reset_modal) - .children(provider_viewer_modal) - .children(profile_add_modal) - .children(mrs_modal) - .children(dialog_layer) - .children(notification_layer) + // Onboarding card sits below the modals so dialogs open above it. + .children(onboarding) + .children(updater_modal) + .children(reset_modal) + .children(provider_viewer_modal) + .children(profile_add_modal) + .children(mrs_modal) + .children(dialog_layer) + .children(notification_layer), + ) + } +} + +impl NyxApp { + fn render_title_bar(&self) -> impl IntoElement + use<> { + let title_bar = TitleBar::new(); + // Route the X through our close logic so it saves bounds; Ctrl+X quits instead. + #[cfg(not(windows))] + let title_bar = title_bar.on_close_window(|_, window, cx| { + save_main_window_bounds(window); + if window.modifiers().control { + crate::app::actions::shutdown_and_quit(cx); + } else { + window.remove_window(); + } + }); + title_bar.child( + h_flex() + .w_full() + .pl_2() + .items_center() + .text_color(rgb(SUBTLE)) + .text_size(px(12.5)) + .font_semibold() + .child("Nyx"), + ) } } -/// Reads `window.window_bounds()` and persists the restore geometry into the app -/// config. Called from the close/hide path (no live gpui borrow conflict). pub(crate) fn save_main_window_bounds(window: &Window) { let b = match window.window_bounds() { WindowBounds::Windowed(b) | WindowBounds::Maximized(b) | WindowBounds::Fullscreen(b) => b, @@ -1132,8 +1069,7 @@ pub(crate) fn save_main_window_bounds(window: &Window) { ); } -/// Opens the main application window. When `silent` is set (silent-start), the -/// window is created but immediately hidden to the tray. +/// Opens the main window; `silent` hides it to the tray immediately. pub fn open_main_window(cx: &mut App, silent: bool) { let window_bounds = match backend::config::load_window_state() { Some((x, y, w, h)) if w >= 400.0 && h >= 300.0 => WindowBounds::Windowed(gpui::Bounds { @@ -1147,15 +1083,13 @@ pub fn open_main_window(cx: &mut App, silent: bool) { titlebar: Some(TitleBar::title_bar_options()), window_bounds: Some(window_bounds), window_min_size: Some(size(px(800.0), px(600.0))), - // Wayland app id: matches nyx.desktop so the compositor finds the - // window icon and groups it (gpui leaves it unset otherwise). + // Wayland app id: matches nyx.desktop so the compositor finds the icon. app_id: Some("nyx".to_owned()), ..Default::default() }; let handle = cx .open_window(options, |window, cx| { - // Sets the OS window title so the taskbar shows "Nyx" on hover. window.set_window_title("Nyx"); let view = cx.new(|cx| NyxApp::new(window, cx)); cx.new(|cx| Root::new(view, window, cx)) @@ -1163,10 +1097,10 @@ pub fn open_main_window(cx: &mut App, silent: bool) { .expect("failed to open main window"); cx.update(|cx| { crate::app::actions::set_main_window(handle, cx); - // Close-to-tray: X hides the window; Ctrl+close disconnects the proxy - // and quits, leaving the core running in the background. + // Close-to-tray; Ctrl+close disconnects and quits. let _ = handle.update(cx, |_root, window, cx| { crate::app::window::remember(window); + crate::app::window::apply_saved_decorations(window); #[cfg(not(windows))] if silent { crate::app::window::hide(window); @@ -1174,7 +1108,7 @@ pub fn open_main_window(cx: &mut App, silent: bool) { window.on_window_should_close(cx, |window, cx| { save_main_window_bounds(window); if window.modifiers().control { - crate::app::actions::disconnect_and_quit(cx); + crate::app::actions::shutdown_and_quit(cx); return true; } // `spawn` so the Win32 hide runs outside this borrow (else it re-enters). @@ -1185,8 +1119,7 @@ pub fn open_main_window(cx: &mut App, silent: bool) { .detach(); false } - // Let the window close; QuitMode::Explicit keeps the app + tray - // alive, and re-showing from the tray recreates the window. + // QuitMode::Explicit keeps the app + tray alive after the window closes. #[cfg(not(windows))] { let _ = (window, cx); @@ -1205,7 +1138,6 @@ pub fn open_main_window(cx: &mut App, silent: bool) { } impl NyxApp { - /// Re-fetches profiles, groups, tun, version from the backend. fn refresh_all(&self, cx: &mut Context) { cx.spawn(async move |_this, cx| { crate::app::bootstrap::refresh_runtime_data(cx).await; @@ -1213,7 +1145,6 @@ impl NyxApp { .detach(); } - /// Opens the "Add profile" modal with a clean form. pub(crate) fn open_profile_add(&mut self, window: &mut Window, cx: &mut Context) { self.profile_edit_id = None; self.profile_add_local = false; @@ -1230,7 +1161,6 @@ impl NyxApp { cx.notify(); } - /// Opens the modal pre-filled with an existing profile's name/link for editing. pub(crate) fn open_profile_edit_info( &mut self, id: String, @@ -1276,7 +1206,6 @@ impl NyxApp { .detach(); } - /// Closes the "Add profile" modal. pub(crate) fn close_profile_add(&mut self, cx: &mut Context) { if self.profile_add_busy { return; @@ -1287,7 +1216,6 @@ impl NyxApp { cx.notify(); } - /// Switches the modal between remote (URL) and local (file) sources. pub(crate) fn profile_add_set_local(&mut self, local: bool, cx: &mut Context) { self.profile_add_local = local; cx.notify(); @@ -1335,8 +1263,7 @@ impl NyxApp { .detach(); } - /// Validates + submits the add/edit modal, then refreshes and closes. Edit - /// mode updates in place (a remote save re-fetches from the URL). + /// Validates + submits the add/edit modal; edit mode updates in place. pub(crate) fn submit_profile_add(&mut self, cx: &mut Context) { let name = self.profile_add_name.read(cx).value().trim().to_string(); let edit_id = self.profile_edit_id.clone(); @@ -1411,7 +1338,6 @@ impl NyxApp { .detach(); } - /// Closes the modal on success, or surfaces the error in the still-open modal. async fn finish_profile_add( this: gpui::WeakEntity, cx: &mut gpui::AsyncApp, @@ -1437,7 +1363,6 @@ impl NyxApp { }); } - /// Activates a profile and hot-reloads the core. pub(crate) fn activate_profile(&mut self, id: String, cx: &mut Context) { cx.spawn(async move |_this, cx| { let _ = runtime::spawn(backend::config::change_current_profile(id)).await; @@ -1446,7 +1371,6 @@ impl NyxApp { .detach(); } - /// Deletes a profile. pub(crate) fn delete_profile(&mut self, id: String, cx: &mut Context) { cx.spawn(async move |_this, cx| { let _ = runtime::spawn(backend::config::remove_profile_item(id)).await; @@ -1455,7 +1379,6 @@ impl NyxApp { .detach(); } - /// Refreshes a remote profile (re-downloads). pub(crate) fn update_profile(&mut self, id: String, cx: &mut Context) { cx.spawn(async move |_this, cx| { if let Ok(Ok(item)) = runtime::spawn(backend::config::get_profile_item(id)).await { @@ -1466,7 +1389,6 @@ impl NyxApp { .detach(); } - /// Re-downloads every remote profile. pub(crate) fn update_all_profiles(&mut self, cx: &mut Context) { let ids: Vec = self .state @@ -1489,7 +1411,6 @@ impl NyxApp { } impl NyxApp { - /// Opens the YAML editor on a profile's content. pub(crate) fn open_profile_editor( &mut self, id: String, @@ -1514,7 +1435,6 @@ impl NyxApp { .detach(); } - /// Persists the editor content and hot-reloads the core. pub(crate) fn save_editor(&mut self, cx: &mut Context) { let Some(editor) = self.editor.clone() else { return; @@ -1536,7 +1456,6 @@ impl NyxApp { .detach(); } - /// Closes the editor and returns to the underlying page. pub(crate) fn close_editor(&mut self, cx: &mut Context) { self.editor = None; self.editor_target = None; @@ -1561,7 +1480,6 @@ fn parse_rule_overrides(text: &str) -> (Vec, Vec, Vec) { } impl NyxApp { - /// Opens the smart rule-override editor on the current profile. pub(crate) fn open_rule_editor(&mut self, window: &mut Window, cx: &mut Context) { let prof = { let st = self.state.read(cx); @@ -1579,7 +1497,6 @@ impl NyxApp { .collect(); let type_select = cx.new(|cx| SelectState::new(types, Some(IndexPath::default()), window, cx)); - // Prefill the payload placeholder with the first type's example. let first_example = crate::ui::pages::rule_example(crate::ui::pages::RULE_TYPES[0]); let payload = cx.new(|cx| InputState::new(window, cx).placeholder(first_example)); @@ -1616,7 +1533,6 @@ impl NyxApp { ) }); - // When the rule type changes, refresh the payload placeholder example. let payload_for_sub = payload.clone(); let type_sub = cx.subscribe_in( &type_select, @@ -1661,7 +1577,6 @@ impl NyxApp { .detach(); } - /// Switches whether a newly added rule goes to the top (prepend) or bottom (append). pub(crate) fn rule_editor_set_append(&mut self, to_append: bool, cx: &mut Context) { if let Some(re) = self.rule_editor.as_mut() { re.to_append = to_append; @@ -1669,7 +1584,6 @@ impl NyxApp { } } - /// Adds the rule described by the form to prepend/append. pub(crate) fn rule_editor_add(&mut self, window: &mut Window, cx: &mut Context) { let Some(re) = self.rule_editor.as_ref() else { return; @@ -1711,7 +1625,6 @@ impl NyxApp { cx.notify(); } - /// Removes a custom (prepend/append) rule by index. pub(crate) fn rule_editor_remove(&mut self, append: bool, idx: usize, cx: &mut Context) { if let Some(re) = self.rule_editor.as_mut() { let list = if append { @@ -1726,7 +1639,6 @@ impl NyxApp { } } - /// Toggles a subscription rule's membership in the `delete` set. pub(crate) fn rule_editor_toggle_delete(&mut self, rule: String, cx: &mut Context) { if let Some(re) = self.rule_editor.as_mut() { if let Some(pos) = re.delete.iter().position(|r| r == &rule) { @@ -1738,7 +1650,6 @@ impl NyxApp { } } - /// Serializes the override file (`prepend`/`append`/`delete`) and reloads. pub(crate) fn save_rule_editor(&mut self, cx: &mut Context) { let Some(re) = self.rule_editor.as_ref() else { return; @@ -1760,7 +1671,6 @@ impl NyxApp { .detach(); } - /// Closes the rule editor without saving. pub(crate) fn close_rule_editor(&mut self, cx: &mut Context) { self.rule_editor = None; cx.notify(); @@ -1768,7 +1678,7 @@ impl NyxApp { } impl NyxApp { - fn render_editor(&self, cx: &mut Context) -> impl IntoElement { + fn render_editor(&self, cx: &mut Context) -> impl IntoElement + use<> { let editor = self.editor.clone(); let (title, readonly) = match &self.editor_target { Some(EditorTarget::Profile { name, .. }) => (name.clone(), false), @@ -1816,7 +1726,6 @@ impl NyxApp { } impl NyxApp { - /// Switches the proxy mode (rule / global / direct) and reloads. pub(crate) fn set_proxy_mode(&mut self, mode: &str, cx: &mut Context) { let mode = mode.to_string(); self.state.update(cx, |st, c| st.set_mode(mode.clone(), c)); @@ -1828,13 +1737,11 @@ impl NyxApp { .detach(); } - /// Shows/hides the Home statistics sidebar. pub(crate) fn toggle_stats(&mut self, cx: &mut Context) { self.stats_open = !self.stats_open; cx.notify(); } - /// Persists a patch to the app config (used by Settings toggles). pub(crate) fn set_app_flag(&mut self, patch: serde_json::Value, cx: &mut Context) { cx.spawn(async move |_this, cx| { let _ = runtime::spawn(backend::config::patch_app_config(patch)).await; @@ -1843,7 +1750,6 @@ impl NyxApp { .detach(); } - /// Opens a Settings sub-page, creating any text inputs it needs (prefilled from config). pub(crate) fn open_settings_sub( &mut self, sub: SettingsSub, @@ -2049,6 +1955,7 @@ impl NyxApp { self.settings_sub = Some(sub); if matches!(sub, SettingsSub::Mihomo) { self.service_status = gpui::SharedString::default(); + self.service_detail = None; self.core_version_installed = gpui::SharedString::default(); self.refresh_service_info(cx); } @@ -2060,7 +1967,6 @@ impl NyxApp { cx.notify(); } - /// Returns from a Settings sub-page to the main settings list. pub(crate) fn close_settings_sub(&mut self, cx: &mut Context) { self.settings_sub = None; self.sub_inputs = SubInputs::default(); @@ -2068,7 +1974,6 @@ impl NyxApp { cx.notify(); } - /// Begins recording a new binding for the given app-config shortcut key. pub(crate) fn start_recording_shortcut( &mut self, key: &'static str, @@ -2080,8 +1985,7 @@ impl NyxApp { cx.notify(); } - /// Handles a keystroke while recording: Esc cancels, Backspace clears, any - /// other combo is saved + re-registered. + /// Esc cancels, Backspace clears, any other combo is saved + re-registered. pub(crate) fn on_recorder_key(&mut self, ev: &gpui::KeyDownEvent, cx: &mut Context) { let Some(key) = self.recording_shortcut else { return; @@ -2106,7 +2010,6 @@ impl NyxApp { } } - /// Persists a single shortcut binding (empty clears it) and reloads hotkeys. fn set_shortcut(&mut self, key: &str, accel: String, cx: &mut Context) { let mut map = serde_json::Map::new(); map.insert( @@ -2120,7 +2023,6 @@ impl NyxApp { self.set_app_flag(serde_json::Value::Object(map), cx); } - /// Persists the text fields of the active sub-page. pub(crate) fn save_settings_sub(&mut self, cx: &mut Context) { match self.settings_sub { Some(SettingsSub::Tun) => { @@ -2305,18 +2207,16 @@ impl NyxApp { }); } - /// Patches the controlled mihomo config under `sniffer` and restarts the core. pub(crate) fn patch_sniffer(&mut self, patch: serde_json::Value, cx: &mut Context) { cx.spawn(async move |_this, cx| { let body = serde_json::json!({ "sniffer": patch }); let _ = runtime::spawn(backend::config::patch_controled_mihomo_config(body)).await; - let _ = runtime::spawn(backend::manager::restart_core()).await; + let _ = runtime::spawn(backend::core::restart()).await; crate::app::bootstrap::refresh_runtime_data(cx).await; }) .detach(); } - /// Re-fetches proxy + rule providers for the Resources page. pub(crate) fn refresh_providers(&mut self, cx: &mut Context) { cx.spawn(async move |this, cx| { let proxies = runtime::spawn(backend::api::get_proxy_providers()).await; @@ -2334,7 +2234,6 @@ impl NyxApp { .detach(); } - /// Updates a single provider (proxy or rule) by name, then refreshes. pub(crate) fn update_provider(&mut self, name: String, is_rule: bool, cx: &mut Context) { if self.resources_busy { return; @@ -2358,7 +2257,6 @@ impl NyxApp { .detach(); } - /// Updates every provider of one kind (the "Update all" button), then refreshes. pub(crate) fn update_all_providers(&mut self, is_rule: bool, cx: &mut Context) { if self.resources_busy { return; @@ -2398,7 +2296,6 @@ impl NyxApp { .detach(); } - /// Opens the provider-content viewer (Resources page) and loads the content. pub(crate) fn open_provider_viewer( &mut self, name: String, @@ -2426,13 +2323,11 @@ impl NyxApp { .detach(); } - /// Closes the provider-content viewer. pub(crate) fn close_provider_viewer(&mut self, cx: &mut Context) { self.provider_viewer = None; cx.notify(); } - /// Asks the core to re-download its geo databases via `PATCH /configs/geo`. pub(crate) fn update_geo(&mut self, cx: &mut Context) { if self.resources_busy { return; @@ -2461,24 +2356,27 @@ impl NyxApp { .detach(); } - /// Patches top-level mihomo config keys (ports, allow-lan, …) and restarts the core. pub(crate) fn patch_core(&mut self, patch: serde_json::Value, cx: &mut Context) { cx.spawn(async move |_this, cx| { let _ = runtime::spawn(backend::config::patch_controled_mihomo_config(patch)).await; - let _ = runtime::spawn(backend::manager::restart_core()).await; + let _ = runtime::spawn(backend::core::restart()).await; crate::app::bootstrap::refresh_runtime_data(cx).await; }) .detach(); } - /// Fetches the Windows service status + installed core version (Mihomo page). pub(crate) fn refresh_service_info(&mut self, cx: &mut Context) { cx.spawn(async move |this, cx| { - let status = runtime::spawn(backend::service::service_status()).await; + let status = runtime::spawn(backend::core::service_status()).await; let version = runtime::spawn(backend::manager::get_installed_version()).await; let _ = this.update(cx, |this, cx| { - if let Ok(Ok(s)) = status { - this.service_status = s.into(); + this.service_managed = backend::core::service_managed(); + if let Ok(s) = status { + this.service_status = s.as_str().into(); + this.service_detail = match s { + backend::core::ServiceStatus::Stale { reason } => Some(reason.into()), + _ => None, + }; } if let Ok(Ok(v)) = version { this.core_version_installed = v.into(); @@ -2489,7 +2387,6 @@ impl NyxApp { .detach(); } - /// Runs a Windows service action, then refreshes runtime data + status. pub(crate) fn service_action(&mut self, action: &'static str, cx: &mut Context) { if self.service_busy { return; @@ -2499,39 +2396,40 @@ impl NyxApp { cx.spawn(async move |this, cx| { let res = runtime::spawn(async move { match action { - "install" => backend::service::install_service().await, - "uninstall" => backend::service::uninstall_service().await, - "start" => backend::service::start_service().await, - "stop" => backend::service::stop_service().await, - "restart" => backend::service::restart_service().await, + "install" => backend::core::install_service().await, + "uninstall" => backend::core::uninstall_service().await, + "start" => backend::core::start_service().await, + "stop" => backend::core::stop_service().await, + "restart" => backend::core::restart_service().await, _ => Ok(()), } }) .await; if let Ok(Err(e)) = &res { log::warn!("[service] {action} failed: {e}"); - let msg = format!("{}: {e}", action); + let detail = e.detail.clone(); cx.update(|cx| { crate::app::actions::notify( - gpui_component::notification::Notification::error(msg), + gpui_component::notification::Notification::error(detail), cx, ); }); } - if matches!(action, "stop" | "uninstall") && matches!(res, Ok(Ok(()))) { - // Stopping/uninstalling kills the core — drop the stale state. - cx.update(|cx| { - AppState::global(cx).update(cx, |st, c| { - st.set_core_status(crate::app::state::CoreStatus::Stopped, c); - st.set_tun_enabled(false, c); - }); - }); - crate::app::bootstrap::refresh_runtime_data(cx).await; - } else if action == "install" && matches!(res, Ok(Ok(()))) { - // Bring the core up (TUN stays off) so its version/groups populate. - crate::app::bootstrap::start_core_disconnected(cx).await; - } else { - crate::app::bootstrap::refresh_runtime_data(cx).await; + match action { + "stop" | "uninstall" if matches!(res, Ok(Ok(()))) => { + // The core went down with the service — drop the stale state. + crate::app::actions::mark_disconnected(cx).await; + crate::app::bootstrap::refresh_runtime_data(cx).await; + } + // Install brings the core up with TUN off; start/restart restores it. + "install" if matches!(res, Ok(Ok(()))) => { + crate::app::bootstrap::start_core_and_streams(cx, false).await; + } + "start" | "restart" if matches!(res, Ok(Ok(()))) => { + let tun = crate::app::bootstrap::restore_tun(); + crate::app::bootstrap::start_core_and_streams(cx, tun).await; + } + _ => crate::app::bootstrap::refresh_runtime_data(cx).await, } let _ = this.update(cx, |this, cx| { this.service_busy = false; @@ -2541,26 +2439,6 @@ impl NyxApp { .detach(); } - /// Grants TUN caps to the Nyx binary via `pkexec setcap` (Linux); effective next launch. - #[cfg(target_os = "linux")] - pub(crate) fn grant_tun(&mut self, cx: &mut Context) { - cx.spawn(async move |_this, cx| { - let res = runtime::spawn(async { backend::elevation::grant_tun_caps() }).await; - let note = match res { - Ok(Ok(())) => gpui_component::notification::Notification::info( - t!("pages.settings.tunGrantedToast").to_string(), - ), - Ok(Err(e)) => gpui_component::notification::Notification::error(e), - Err(_) => gpui_component::notification::Notification::error( - t!("pages.settings.tunGrantFailed").to_string(), - ), - }; - cx.update(|cx| crate::app::actions::notify(note, cx)); - }) - .detach(); - } - - /// Persists + (re)installs the core channel (`mihomo` stable / `mihomo-alpha`), then restarts. pub(crate) fn install_core(&mut self, channel: &'static str, cx: &mut Context) { if self.service_busy { return; @@ -2580,7 +2458,7 @@ impl NyxApp { ); }); } - let _ = runtime::spawn(backend::manager::restart_core()).await; + let _ = runtime::spawn(backend::core::restart()).await; crate::app::bootstrap::refresh_runtime_data(cx).await; let _ = this.update(cx, |this, cx| { this.service_busy = false; @@ -2590,18 +2468,16 @@ impl NyxApp { .detach(); } - /// Patches the controlled mihomo config under `tun` and restarts the core. pub(crate) fn patch_tun(&mut self, patch: serde_json::Value, cx: &mut Context) { cx.spawn(async move |_this, cx| { let body = serde_json::json!({ "tun": patch }); let _ = runtime::spawn(backend::config::patch_controled_mihomo_config(body)).await; - let _ = runtime::spawn(backend::manager::restart_core()).await; + let _ = runtime::spawn(backend::core::restart()).await; crate::app::bootstrap::refresh_runtime_data(cx).await; }) .detach(); } - /// Flips an override flag (`controlDns`/`controlSniff`/`controlTun`) and rebuilds the core config. pub(crate) fn toggle_override( &mut self, key: &'static str, @@ -2611,24 +2487,22 @@ impl NyxApp { cx.spawn(async move |_this, cx| { let patch = serde_json::json!({ key: checked }); let _ = runtime::spawn(backend::config::patch_app_config(patch)).await; - let _ = runtime::spawn(backend::manager::restart_core()).await; + let _ = runtime::spawn(backend::core::restart()).await; crate::app::bootstrap::refresh_runtime_data(cx).await; }) .detach(); } - /// Patches the controlled mihomo config under `dns` and restarts the core. pub(crate) fn patch_dns(&mut self, patch: serde_json::Value, cx: &mut Context) { cx.spawn(async move |_this, cx| { let body = serde_json::json!({ "dns": patch }); let _ = runtime::spawn(backend::config::patch_controled_mihomo_config(body)).await; - let _ = runtime::spawn(backend::manager::restart_core()).await; + let _ = runtime::spawn(backend::core::restart()).await; crate::app::bootstrap::refresh_runtime_data(cx).await; }) .detach(); } - /// Closes the connections with the given ids; the next `/connections` poll refreshes the list. pub(crate) fn close_connections(&mut self, ids: Vec, cx: &mut Context) { cx.spawn(async move |_this, _cx| { for id in ids { @@ -2639,7 +2513,6 @@ impl NyxApp { .detach(); } - /// Closes every active connection (the page-level "close all" button). pub(crate) fn close_all_connections(&mut self, cx: &mut Context) { cx.spawn(async move |_this, _cx| { let _ = runtime::spawn(backend::api::close_all_connections()).await; diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 62f9570..b2ce35f 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1,49 +1,47 @@ -use gpui::{linear_color_stop, linear_gradient, rgb, rgba, App, Background}; +use gpui::{App, Background, linear_color_stop, linear_gradient, rgb, rgba}; use gpui_component::Theme; pub const TEXT: u32 = 0xEEF3F7; // headings / primary text pub const SUBTLE: u32 = 0xAEBCCB; // secondary text, title-bar label -pub const MUTED: u32 = 0x8DA0B0; // muted body +pub const MUTED: u32 = 0x8DA0B0; pub const MUTED2: u32 = 0x7D8E9D; // dimmer (mono captions) pub const MUTED3: u32 = 0x6B7C8A; // dimmest (placeholders, icons-off) pub const MUTED4: u32 = 0x5D6C7A; // labels / section eyebrows pub const BG: u32 = 0x06080B; // app root -pub const WINDOW_BG: u32 = 0x0B1014; // window interior base +pub const WINDOW_BG: u32 = 0x0B1014; pub const TITLEBAR_BG: u32 = 0x0C1217; pub const TITLEBAR_BORDER: u32 = 0x19212A; -pub const RAIL_BG: u32 = 0x080B0E; // navigation rail +pub const RAIL_BG: u32 = 0x080B0E; pub const RAIL_BORDER: u32 = 0x19212A; -pub const CARD_BG: u32 = 0x121922; // standard card +pub const CARD_BG: u32 = 0x121922; pub const CARD_BORDER: u32 = 0x232D38; pub const STAT_BORDER: u32 = 0x212A34; // softer card border -pub const ACTIVE_CARD_BG: u32 = 0x15202B; // selected / highlighted card +pub const ACTIVE_CARD_BG: u32 = 0x15202B; pub const ACTIVE_CARD_BORDER: u32 = 0x2C3A48; pub const CONTROL_BG: u32 = 0x10171E; // inputs, small buttons, segmented pub const CONTROL_BORDER: u32 = 0x222C36; pub const PANEL_BG: u32 = 0x0A0F14; // log/console panels pub const DIVIDER: u32 = 0x1C2630; -pub const GREEN: u32 = 0x35C97C; // primary accent +pub const GREEN: u32 = 0x35C97C; pub const GREEN_HI: u32 = 0x49DB8D; // bright text / active icons pub const GREEN_LO: u32 = 0x26B06D; // power-button bottom / active state pub const CYAN: u32 = 0x2AA9C9; // green→cyan gradient end pub const ACCENT: u32 = GREEN; pub const BLUE: u32 = 0x46B6D6; // download / info -pub const AMBER: u32 = 0xE0A030; // warning -pub const RED: u32 = 0xE0564B; // danger +pub const AMBER: u32 = 0xE0A030; +pub const RED: u32 = 0xE0564B; pub const RED_HI: u32 = 0xEE7D72; // danger text on tint pub const GOOD: u32 = 0x49DB8D; pub const WARN: u32 = 0xE0A030; pub const BAD: u32 = 0xE0564B; -// translucent green for active-nav tint / inset glow (0xRRGGBBAA) pub const GREEN_TINT: u32 = 0x35C97C24; // ~14% fill pub const GREEN_INSET: u32 = 0x35C97C42; // ~26% inset border pub const GREEN_GLOW: u32 = 0x35C97C1B; // ~10% soft glow -// translucent strokes / hover overlays (white-on-dark) pub const STROKE: u32 = 0xFFFFFF14; // ~8% white pub const OVERLAY_SOFT: u32 = 0xFFFFFF0D; // ~5% white hover @@ -52,7 +50,6 @@ pub const NYX_BG: u32 = CONTROL_BG; pub const UP_COLOR: u32 = GREEN_HI; pub const DOWN_COLOR: u32 = BLUE; -/// The atmospheric window background (faint green-tinted top fading to near-black). pub fn content_bg() -> Background { linear_gradient( 180.0, @@ -61,7 +58,6 @@ pub fn content_bg() -> Background { ) } -/// The bright green power-button gradient (top-light to bottom-dark). pub fn power_on_bg() -> Background { linear_gradient( 160.0, @@ -70,7 +66,6 @@ pub fn power_on_bg() -> Background { ) } -/// The green→cyan brand gradient used on the logo mark and progress fills. pub fn brand_gradient() -> Background { linear_gradient( 150.0, @@ -79,8 +74,8 @@ pub fn brand_gradient() -> Background { ) } -/// Overrides the gpui-component theme palette to match Nyx. Call once after -/// `gpui_component::Theme::change(Dark, …)`. +/// Overrides the gpui-component palette to match Nyx. Call once after +/// `Theme::change(Dark, …)`. pub fn apply(cx: &mut App) { let t = Theme::global_mut(cx); let c = &mut t.colors;