Compare commits

..

8 Commits

Author SHA1 Message Date
Tunglies
830c0773dc
refactor: migrate backoff crate to backon (#6718)
Replace backoff 0.4.0 with backon 1.6.0 for retry logic.
2026-04-03 13:21:04 +00:00
Tunglies
5da9f99698
fix: prevent TUN from being falsely disabled during startup
- Add 10s startup grace period before TUN auto-disable logic activates;
  service IPC may not be ready when the frontend first queries, causing
  a transient isServiceOk=false that incorrectly persists
- Replace placeholderData (which set isLoading=false with stale data)
  with a proper isStartingUp guard; query now polls every 2s during
  startup to catch service readiness quickly
- Add 'getSystemState' to refresh-verge-config invalidation keys to
  fix key mismatch that prevented event-driven refetches from working
2026-04-03 21:20:37 +08:00
renovate[bot]
decdeffcf6
chore(deps): update github/gh-aw-actions action to v0.65.7 (#6709)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 11:29:59 +00:00
git-sac
7b7dc79c74
fix: decode percent-encoded username/password before building Basic Auth header (#6716)
URLs with percent-encoded characters in credentials (e.g. %40 for @) were
being double-encoded after Url::parse() + as_str() serialization, causing
the constructed Basic Auth header to contain the wrong credentials and
resulting in 401 Unauthorized errors.
2026-04-03 11:29:38 +00:00
Tunglies
fa4557337b
fix: adjust axios dependency to devDependency 2026-04-03 17:35:05 +08:00
Tunglies
d6d15652ca
refactor: migrate react-virtuoso to @tanstack/react-virtual 2026-04-03 17:13:13 +08:00
Tunglies
a73fafaf9f
refactor: migrate SWR to TanStack Query v5 (#6713)
Replace swr with @tanstack/react-query v5 across all hooks, providers,
and components. Introduce singleton QueryClient, WS subscription pattern
via useQuery+useEffect, and enforce component-layer cache access contract.
2026-04-03 08:15:51 +00:00
Tunglies
6f4ddb6db3
chore: update aw file 2026-04-03 15:15:26 +08:00
44 changed files with 1541 additions and 1361 deletions

View File

@ -12,7 +12,7 @@
# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
# #
# This file was automatically generated by gh-aw (v0.64.0). DO NOT EDIT. # This file was automatically generated by gh-aw (v0.62.5). DO NOT EDIT.
# #
# To update this file, edit the corresponding .md file and run: # To update this file, edit the corresponding .md file and run:
# gh aw compile # gh aw compile
@ -24,22 +24,17 @@
# signs of one-shot AI-generated changes, then posts a maintainer-focused # signs of one-shot AI-generated changes, then posts a maintainer-focused
# comment when the risk is high enough to warrant follow-up. # comment when the risk is high enough to warrant follow-up.
# #
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"6b65c1d60eaf3a611755c7def95a13c3673df2f25ff609508e99d59c77d57d8d","compiler_version":"v0.64.0","strict":true,"agent_id":"copilot"} # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"90464e2f3880b176a101872f067e5873c3c3f92ac7ab6821861dad3d11f835df","compiler_version":"v0.62.5","strict":true,"agent_id":"copilot"}
name: "PR AI Slop Review" name: "PR AI Slop Review"
"on": "on":
pull_request_target: pull_request_target:
types: types:
- opened - opened
- reopened
- synchronize - synchronize
# roles: all # Roles processed as role check in pre-activation job # roles: all # Roles processed as role check in pre-activation job
workflow_dispatch: workflow_dispatch:
inputs:
aw_context:
default: ""
description: Agent caller context (used internally by Agentic Workflows).
required: false
type: string
permissions: {} permissions: {}
@ -65,7 +60,7 @@ jobs:
title: ${{ steps.sanitized.outputs.title }} title: ${{ steps.sanitized.outputs.title }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5 uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Generate agentic run info - name: Generate agentic run info
@ -73,17 +68,17 @@ jobs:
env: env:
GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_ID: "copilot"
GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }}
GH_AW_INFO_VERSION: "latest" GH_AW_INFO_VERSION: ""
GH_AW_INFO_AGENT_VERSION: "latest" GH_AW_INFO_AGENT_VERSION: "latest"
GH_AW_INFO_CLI_VERSION: "v0.64.0" GH_AW_INFO_CLI_VERSION: "v0.62.5"
GH_AW_INFO_WORKFLOW_NAME: "PR AI Slop Review" GH_AW_INFO_WORKFLOW_NAME: "PR AI Slop Review"
GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_EXPERIMENTAL: "false"
GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
GH_AW_INFO_STAGED: "false" GH_AW_INFO_STAGED: "false"
GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]'
GH_AW_INFO_FIREWALL_ENABLED: "true" GH_AW_INFO_FIREWALL_ENABLED: "true"
GH_AW_INFO_AWF_VERSION: "v0.25.0" GH_AW_INFO_AWF_VERSION: "v0.24.5"
GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_AWMG_VERSION: ""
GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FIREWALL_TYPE: "squid"
GH_AW_COMPILED_STRICT: "true" GH_AW_COMPILED_STRICT: "true"
@ -130,7 +125,7 @@ jobs:
- name: Create prompt with built-in context - name: Create prompt with built-in context
env: env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_ACTOR: ${{ github.actor }}
GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
@ -139,7 +134,6 @@ jobs:
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
# poutine:ignore untrusted_checkout_exec
run: | run: |
bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh
{ {
@ -152,7 +146,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_EOF' cat << 'GH_AW_PROMPT_EOF'
<safe-output-tools> <safe-output-tools>
Tools: add_comment, add_labels, remove_labels(max:2), missing_tool, missing_data, noop Tools: add_comment, add_labels, remove_labels, missing_tool, missing_data, noop
</safe-output-tools> </safe-output-tools>
<github-context> <github-context>
The following GitHub context information is available for this workflow: The following GitHub context information is available for this workflow:
@ -237,16 +231,14 @@ jobs:
- name: Validate prompt placeholders - name: Validate prompt placeholders
env: env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
# poutine:ignore untrusted_checkout_exec
run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh
- name: Print prompt - name: Print prompt
env: env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
# poutine:ignore untrusted_checkout_exec
run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh
- name: Upload activation artifact - name: Upload activation artifact
if: success() if: success()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: activation name: activation
path: | path: |
@ -270,6 +262,8 @@ jobs:
GH_AW_WORKFLOW_ID_SANITIZED: praislopreview GH_AW_WORKFLOW_ID_SANITIZED: praislopreview
outputs: outputs:
checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
detection_success: ${{ steps.detection_conclusion.outputs.success }}
has_patch: ${{ steps.collect_output.outputs.has_patch }} has_patch: ${{ steps.collect_output.outputs.has_patch }}
inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }}
model: ${{ needs.activation.outputs.model }} model: ${{ needs.activation.outputs.model }}
@ -277,15 +271,14 @@ jobs:
output_types: ${{ steps.collect_output.outputs.output_types }} output_types: ${{ steps.collect_output.outputs.output_types }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5 uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Set runtime paths - name: Set runtime paths
id: set-runtime-paths
run: | run: |
echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT" echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_ENV"
echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT" echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_ENV"
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT" echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_ENV"
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
@ -327,22 +320,16 @@ jobs:
env: env:
GH_HOST: github.com GH_HOST: github.com
- name: Install AWF binary - name: Install AWF binary
run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.0 run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5
- name: Parse integrity filter lists
id: parse-guard-vars
env:
GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}
GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}
run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh
- name: Download container images - name: Download container images
run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.0 ghcr.io/github/gh-aw-firewall/squid:0.25.0 ghcr.io/github/gh-aw-mcpg:v0.2.6 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.20 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine
- name: Write Safe Outputs Config - name: Write Safe Outputs Config
run: | run: |
mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF'
{"add_comment":{"hide_older_comments":true,"max":1},"add_labels":{"allowed":["ai-slop:high","ai-slop:med"],"max":1},"mentions":{"enabled":false},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"remove_labels":{"allowed":["ai-slop:high","ai-slop:med"],"max":2}} {"add_comment":{"max":1},"add_labels":{"allowed":["ai-slop:high","ai-slop:med"],"max":1},"mentions":{"enabled":false},"missing_data":{},"missing_tool":{},"noop":{"max":1},"remove_labels":{"allowed":["ai-slop:high","ai-slop:med"],"max":2}}
GH_AW_SAFE_OUTPUTS_CONFIG_EOF GH_AW_SAFE_OUTPUTS_CONFIG_EOF
- name: Write Safe Outputs Tools - name: Write Safe Outputs Tools
run: | run: |
@ -516,7 +503,7 @@ jobs:
- name: Start MCP Gateway - name: Start MCP Gateway
id: start-mcp-gateway id: start-mcp-gateway
env: env:
GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@ -536,7 +523,7 @@ jobs:
export DEBUG="*" export DEBUG="*"
export GH_AW_ENGINE="copilot" export GH_AW_ENGINE="copilot"
export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.6' export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.20'
mkdir -p /home/runner/.copilot mkdir -p /home/runner/.copilot
cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
@ -553,8 +540,6 @@ jobs:
}, },
"guard-policies": { "guard-policies": {
"allow-only": { "allow-only": {
"approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }},
"blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }},
"min-integrity": "unapproved", "min-integrity": "unapproved",
"repos": "all" "repos": "all"
} }
@ -599,7 +584,7 @@ jobs:
set -o pipefail set -o pipefail
touch /tmp/gh-aw/agent-step-summary.md touch /tmp/gh-aw/agent-step-summary.md
# shellcheck disable=SC1003 # shellcheck disable=SC1003
sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.0 --skip-pull --enable-api-proxy \ sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.5 --skip-pull --enable-api-proxy \
-- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
env: env:
COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_AGENT_RUNNER_TYPE: STANDALONE
@ -608,8 +593,8 @@ jobs:
GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json
GH_AW_PHASE: agent GH_AW_PHASE: agent
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
GH_AW_VERSION: v0.64.0 GH_AW_VERSION: v0.62.5
GITHUB_API_URL: ${{ github.api_url }} GITHUB_API_URL: ${{ github.api_url }}
GITHUB_AW: true GITHUB_AW: true
GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_HEAD_REF: ${{ github.head_ref }}
@ -686,8 +671,6 @@ jobs:
run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh
- name: Copy Safe Outputs - name: Copy Safe Outputs
if: always() if: always()
env:
GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
run: | run: |
mkdir -p /tmp/gh-aw mkdir -p /tmp/gh-aw
cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
@ -696,7 +679,7 @@ jobs:
if: always() if: always()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env: env:
GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
GH_AW_ALLOWED_GITHUB_REFS: "" GH_AW_ALLOWED_GITHUB_REFS: ""
GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_SERVER_URL: ${{ github.server_url }}
@ -742,16 +725,10 @@ jobs:
else else
echo 'AWF binary not installed, skipping firewall log summary' echo 'AWF binary not installed, skipping firewall log summary'
fi fi
- name: Write agent output placeholder if missing
if: always()
run: |
if [ ! -f /tmp/gh-aw/agent_output.json ]; then
echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
fi
- name: Upload agent artifacts - name: Upload agent artifacts
if: always() if: always()
continue-on-error: true continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: agent name: agent
path: | path: |
@ -759,168 +736,19 @@ jobs:
/tmp/gh-aw/sandbox/agent/logs/ /tmp/gh-aw/sandbox/agent/logs/
/tmp/gh-aw/redacted-urls.log /tmp/gh-aw/redacted-urls.log
/tmp/gh-aw/mcp-logs/ /tmp/gh-aw/mcp-logs/
/tmp/gh-aw/proxy-logs/ /tmp/gh-aw/sandbox/firewall/logs/
!/tmp/gh-aw/proxy-logs/proxy-tls/
/tmp/gh-aw/agent-stdio.log /tmp/gh-aw/agent-stdio.log
/tmp/gh-aw/agent/ /tmp/gh-aw/agent/
/tmp/gh-aw/safeoutputs.jsonl /tmp/gh-aw/safeoutputs.jsonl
/tmp/gh-aw/agent_output.json /tmp/gh-aw/agent_output.json
/tmp/gh-aw/aw-*.patch
if-no-files-found: ignore if-no-files-found: ignore
- name: Upload firewall audit logs # --- Threat Detection (inline) ---
if: always()
continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: firewall-audit-logs
path: |
/tmp/gh-aw/sandbox/firewall/logs/
/tmp/gh-aw/sandbox/firewall/audit/
if-no-files-found: ignore
conclusion:
needs:
- activation
- agent
- detection
- safe_outputs
if: always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true')
runs-on: ubuntu-slim
permissions:
contents: read
discussions: write
issues: write
pull-requests: write
concurrency:
group: "gh-aw-conclusion-pr-ai-slop-review"
cancel-in-progress: false
outputs:
noop_message: ${{ steps.noop.outputs.noop_message }}
tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
total_count: ${{ steps.missing_tool.outputs.total_count }}
steps:
- name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5
with:
destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact
id: download-agent-output
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: agent
path: /tmp/gh-aw/
- name: Setup agent output environment variable
id: setup-agent-output-env
if: steps.download-agent-output.outcome == 'success'
run: |
mkdir -p /tmp/gh-aw/
find "/tmp/gh-aw/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Process No-Op Messages
id: noop
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_NOOP_MAX: "1"
GH_AW_WORKFLOW_NAME: "PR AI Slop Review"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs');
await main();
- name: Record Missing Tool
id: missing_tool
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_WORKFLOW_NAME: "PR AI Slop Review"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
await main();
- name: Handle Agent Failure
id: handle_agent_failure
if: always()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_WORKFLOW_NAME: "PR AI Slop Review"
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
GH_AW_WORKFLOW_ID: "pr-ai-slop-review"
GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
GH_AW_GROUP_REPORTS: "false"
GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
GH_AW_TIMEOUT_MINUTES: "20"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
await main();
- name: Handle No-Op Message
id: handle_noop_message
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_WORKFLOW_NAME: "PR AI Slop Review"
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }}
GH_AW_NOOP_REPORT_AS_ISSUE: "true"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
await main();
detection:
needs: agent
if: always() && needs.agent.result != 'skipped'
runs-on: ubuntu-latest
outputs:
detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
detection_success: ${{ steps.detection_conclusion.outputs.success }}
steps:
- name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5
with:
destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact
id: download-agent-output
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: agent
path: /tmp/gh-aw/
- name: Setup agent output environment variable
id: setup-agent-output-env
if: steps.download-agent-output.outcome == 'success'
run: |
mkdir -p /tmp/gh-aw/
find "/tmp/gh-aw/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
# --- Threat Detection ---
- name: Download container images
run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.0 ghcr.io/github/gh-aw-firewall/squid:0.25.0
- name: Check if detection needed - name: Check if detection needed
id: detection_guard id: detection_guard
if: always() if: always()
env: env:
OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }}
HAS_PATCH: ${{ needs.agent.outputs.has_patch }} HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }}
run: | run: |
if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then
echo "run_detection=true" >> "$GITHUB_OUTPUT" echo "run_detection=true" >> "$GITHUB_OUTPUT"
@ -952,7 +780,7 @@ jobs:
env: env:
WORKFLOW_NAME: "PR AI Slop Review" WORKFLOW_NAME: "PR AI Slop Review"
WORKFLOW_DESCRIPTION: "Reviews incoming pull requests for missing issue linkage and high-confidence\nsigns of one-shot AI-generated changes, then posts a maintainer-focused\ncomment when the risk is high enough to warrant follow-up." WORKFLOW_DESCRIPTION: "Reviews incoming pull requests for missing issue linkage and high-confidence\nsigns of one-shot AI-generated changes, then posts a maintainer-focused\ncomment when the risk is high enough to warrant follow-up."
HAS_PATCH: ${{ needs.agent.outputs.has_patch }} HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }}
with: with:
script: | script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
@ -964,12 +792,6 @@ jobs:
run: | run: |
mkdir -p /tmp/gh-aw/threat-detection mkdir -p /tmp/gh-aw/threat-detection
touch /tmp/gh-aw/threat-detection/detection.log touch /tmp/gh-aw/threat-detection/detection.log
- name: Install GitHub Copilot CLI
run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest
env:
GH_HOST: github.com
- name: Install AWF binary
run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.0
- name: Execute GitHub Copilot CLI - name: Execute GitHub Copilot CLI
if: always() && steps.detection_guard.outputs.run_detection == 'true' if: always() && steps.detection_guard.outputs.run_detection == 'true'
id: detection_agentic_execution id: detection_agentic_execution
@ -986,15 +808,15 @@ jobs:
set -o pipefail set -o pipefail
touch /tmp/gh-aw/agent-step-summary.md touch /tmp/gh-aw/agent-step-summary.md
# shellcheck disable=SC1003 # shellcheck disable=SC1003
sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.0 --skip-pull --enable-api-proxy \ sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.24.5 --skip-pull --enable-api-proxy \
-- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
env: env:
COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_AGENT_RUNNER_TYPE: STANDALONE
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
COPILOT_MODEL: gpt-5.1-codex-mini COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }}
GH_AW_PHASE: detection GH_AW_PHASE: detection
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
GH_AW_VERSION: v0.64.0 GH_AW_VERSION: v0.62.5
GITHUB_API_URL: ${{ github.api_url }} GITHUB_API_URL: ${{ github.api_url }}
GITHUB_AW: true GITHUB_AW: true
GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_HEAD_REF: ${{ github.head_ref }}
@ -1007,31 +829,153 @@ jobs:
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot] GIT_COMMITTER_NAME: github-actions[bot]
XDG_CONFIG_HOME: /home/runner XDG_CONFIG_HOME: /home/runner
- name: Upload threat detection log - name: Parse threat detection results
id: parse_detection_results
if: always() && steps.detection_guard.outputs.run_detection == 'true' if: always() && steps.detection_guard.outputs.run_detection == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: detection
path: /tmp/gh-aw/threat-detection/detection.log
if-no-files-found: ignore
- name: Parse and conclude threat detection
id: detection_conclusion
if: always()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }}
with: with:
script: | script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io); setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs');
await main(); await main();
- name: Upload threat detection log
if: always() && steps.detection_guard.outputs.run_detection == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: detection
path: /tmp/gh-aw/threat-detection/detection.log
if-no-files-found: ignore
- name: Set detection conclusion
id: detection_conclusion
if: always()
env:
RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }}
DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }}
run: |
if [[ "$RUN_DETECTION" != "true" ]]; then
echo "conclusion=skipped" >> "$GITHUB_OUTPUT"
echo "success=true" >> "$GITHUB_OUTPUT"
echo "Detection was not needed, marking as skipped"
elif [[ "$DETECTION_SUCCESS" == "true" ]]; then
echo "conclusion=success" >> "$GITHUB_OUTPUT"
echo "success=true" >> "$GITHUB_OUTPUT"
echo "Detection passed successfully"
else
echo "conclusion=failure" >> "$GITHUB_OUTPUT"
echo "success=false" >> "$GITHUB_OUTPUT"
echo "Detection found issues"
fi
conclusion:
needs:
- activation
- agent
- safe_outputs
if: always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true')
runs-on: ubuntu-slim
permissions:
contents: read
discussions: write
issues: write
pull-requests: write
concurrency:
group: "gh-aw-conclusion-pr-ai-slop-review"
cancel-in-progress: false
outputs:
noop_message: ${{ steps.noop.outputs.noop_message }}
tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
total_count: ${{ steps.missing_tool.outputs.total_count }}
steps:
- name: Setup Scripts
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7
with:
destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact
id: download-agent-output
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: agent
path: /tmp/gh-aw/
- name: Setup agent output environment variable
if: steps.download-agent-output.outcome == 'success'
run: |
mkdir -p /tmp/gh-aw/
find "/tmp/gh-aw/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV"
- name: Process No-Op Messages
id: noop
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
GH_AW_NOOP_MAX: "1"
GH_AW_WORKFLOW_NAME: "PR AI Slop Review"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs');
await main();
- name: Record Missing Tool
id: missing_tool
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
GH_AW_WORKFLOW_NAME: "PR AI Slop Review"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
await main();
- name: Handle Agent Failure
id: handle_agent_failure
if: always()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
GH_AW_WORKFLOW_NAME: "PR AI Slop Review"
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
GH_AW_WORKFLOW_ID: "pr-ai-slop-review"
GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
GH_AW_GROUP_REPORTS: "false"
GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
GH_AW_TIMEOUT_MINUTES: "20"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
await main();
- name: Handle No-Op Message
id: handle_noop_message
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
GH_AW_WORKFLOW_NAME: "PR AI Slop Review"
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }}
GH_AW_NOOP_REPORT_AS_ISSUE: "true"
with:
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
await main();
safe_outputs: safe_outputs:
needs: needs: agent
- agent if: (!cancelled()) && needs.agent.result != 'skipped' && needs.agent.outputs.detection_success == 'true'
- detection
if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
runs-on: ubuntu-slim runs-on: ubuntu-slim
permissions: permissions:
contents: read contents: read
@ -1042,7 +986,6 @@ jobs:
env: env:
GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/pr-ai-slop-review" GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/pr-ai-slop-review"
GH_AW_ENGINE_ID: "copilot" GH_AW_ENGINE_ID: "copilot"
GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
GH_AW_WORKFLOW_ID: "pr-ai-slop-review" GH_AW_WORKFLOW_ID: "pr-ai-slop-review"
GH_AW_WORKFLOW_NAME: "PR AI Slop Review" GH_AW_WORKFLOW_NAME: "PR AI Slop Review"
outputs: outputs:
@ -1056,7 +999,7 @@ jobs:
process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
steps: steps:
- name: Setup Scripts - name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5 uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact - name: Download agent output artifact
@ -1067,26 +1010,24 @@ jobs:
name: agent name: agent
path: /tmp/gh-aw/ path: /tmp/gh-aw/
- name: Setup agent output environment variable - name: Setup agent output environment variable
id: setup-agent-output-env
if: steps.download-agent-output.outcome == 'success' if: steps.download-agent-output.outcome == 'success'
run: | run: |
mkdir -p /tmp/gh-aw/ mkdir -p /tmp/gh-aw/
find "/tmp/gh-aw/" -type f -print find "/tmp/gh-aw/" -type f -print
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV"
- name: Configure GH_HOST for enterprise compatibility - name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash shell: bash
run: | run: |
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
GH_HOST="${GITHUB_SERVER_URL#https://}" GH_HOST="${GITHUB_SERVER_URL#https://}"
GH_HOST="${GH_HOST#http://}" GH_HOST="${GH_HOST#http://}"
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_OUTPUT" echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- name: Process Safe Outputs - name: Process Safe Outputs
id: process_safe_outputs id: process_safe_outputs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env: env:
GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_API_URL: ${{ github.api_url }} GITHUB_API_URL: ${{ github.api_url }}
@ -1098,9 +1039,9 @@ jobs:
setupGlobals(core, github, context, exec, io); setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs');
await main(); await main();
- name: Upload Safe Output Items - name: Upload safe output items
if: always() if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: safe-output-items name: safe-output-items
path: /tmp/gh-aw/safe-output-items.jsonl path: /tmp/gh-aw/safe-output-items.jsonl

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ target
CLAUDE.md CLAUDE.md
.vfox.toml .vfox.toml
.vfox/ .vfox/
.claude

29
Cargo.lock generated
View File

@ -559,16 +559,13 @@ dependencies = [
] ]
[[package]] [[package]]
name = "backoff" name = "backon"
version = "0.4.0" version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
dependencies = [ dependencies = [
"futures-core", "fastrand 2.3.0",
"getrandom 0.2.17", "gloo-timers",
"instant",
"pin-project-lite",
"rand 0.8.5",
"tokio", "tokio",
] ]
@ -1117,7 +1114,7 @@ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
"async-trait", "async-trait",
"backoff", "backon",
"base64 0.22.1", "base64 0.22.1",
"bitflags 2.11.0", "bitflags 2.11.0",
"boa_engine", "boa_engine",
@ -1243,7 +1240,7 @@ dependencies = [
[[package]] [[package]]
name = "clash_verge_service_ipc" name = "clash_verge_service_ipc"
version = "2.2.0" version = "2.2.0"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc#b73568a9ecc9e62577e9ce81a123b554f06a9fb3" source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc#62e0fe76279350303373e13cbdb6af32a04abe0f"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"compact_str", "compact_str",
@ -3039,6 +3036,18 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.18.0" version = "0.18.0"

View File

@ -72,7 +72,7 @@ export default defineConfig([
'@eslint-react/no-children-for-each': 'error', '@eslint-react/no-children-for-each': 'error',
'@eslint-react/no-children-map': 'error', '@eslint-react/no-children-map': 'error',
'@eslint-react/no-children-only': 'error', '@eslint-react/no-children-only': 'error',
'@eslint-react/no-children-prop': 'error', '@eslint-react/jsx-no-children-prop': 'error',
'@eslint-react/no-children-to-array': 'error', '@eslint-react/no-children-to-array': 'error',
'@eslint-react/no-class-component': 'error', '@eslint-react/no-class-component': 'error',
'@eslint-react/no-clone-element': 'error', '@eslint-react/no-clone-element': 'error',
@ -86,7 +86,7 @@ export default defineConfig([
'@eslint-react/no-unstable-default-props': 'warn', '@eslint-react/no-unstable-default-props': 'warn',
'@eslint-react/no-unused-class-component-members': 'error', '@eslint-react/no-unused-class-component-members': 'error',
'@eslint-react/no-unused-state': 'error', '@eslint-react/no-unused-state': 'error',
'@eslint-react/no-useless-fragment': 'warn', '@eslint-react/jsx-no-useless-fragment': 'warn',
'@eslint-react/prefer-destructuring-assignment': 'warn', '@eslint-react/prefer-destructuring-assignment': 'warn',
// TypeScript // TypeScript

View File

@ -44,6 +44,7 @@
"@mui/icons-material": "^7.3.9", "@mui/icons-material": "^7.3.9",
"@mui/lab": "7.0.0-beta.17", "@mui/lab": "7.0.0-beta.17",
"@mui/material": "^7.3.9", "@mui/material": "^7.3.9",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "2.10.1", "@tauri-apps/api": "2.10.1",
@ -55,7 +56,6 @@
"@tauri-apps/plugin-shell": "2.3.5", "@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-updater": "2.10.0", "@tauri-apps/plugin-updater": "2.10.0",
"ahooks": "^3.9.6", "ahooks": "^3.9.6",
"axios": "^1.13.6",
"cidr-block": "^2.3.0", "cidr-block": "^2.3.0",
"dayjs": "1.11.20", "dayjs": "1.11.20",
"foxact": "^0.3.0", "foxact": "^0.3.0",
@ -74,9 +74,7 @@
"react-i18next": "17.0.2", "react-i18next": "17.0.2",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"react-virtuoso": "^4.18.3",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"swr": "^2.4.1",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#revert", "tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#revert",
"types-pac": "^1.0.3", "types-pac": "^1.0.3",
"validator": "^13.15.26" "validator": "^13.15.26"
@ -94,6 +92,7 @@
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.0.0", "@vitejs/plugin-legacy": "^8.0.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"axios": "^1.13.6",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"cli-color": "^2.0.4", "cli-color": "^2.0.4",
"commander": "^14.0.3", "commander": "^14.0.3",

52
pnpm-lock.yaml generated
View File

@ -38,6 +38,9 @@ importers:
'@mui/material': '@mui/material':
specifier: ^7.3.9 specifier: ^7.3.9
version: 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tanstack/react-query':
specifier: ^5.96.1
version: 5.96.1(react@19.2.4)
'@tanstack/react-table': '@tanstack/react-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -71,9 +74,6 @@ importers:
ahooks: ahooks:
specifier: ^3.9.6 specifier: ^3.9.6
version: 3.9.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 3.9.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
axios:
specifier: ^1.13.6
version: 1.14.0
cidr-block: cidr-block:
specifier: ^2.3.0 specifier: ^2.3.0
version: 2.3.0 version: 2.3.0
@ -128,15 +128,9 @@ importers:
react-router: react-router:
specifier: ^7.13.1 specifier: ^7.13.1
version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-virtuoso:
specifier: ^4.18.3
version: 4.18.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rehype-raw: rehype-raw:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0 version: 7.0.0
swr:
specifier: ^2.4.1
version: 2.4.1(react@19.2.4)
tauri-plugin-mihomo-api: tauri-plugin-mihomo-api:
specifier: github:clash-verge-rev/tauri-plugin-mihomo#revert specifier: github:clash-verge-rev/tauri-plugin-mihomo#revert
version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1cc80bc0fbe1245315617f4cecd93710a152325b version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1cc80bc0fbe1245315617f4cecd93710a152325b
@ -186,6 +180,9 @@ importers:
adm-zip: adm-zip:
specifier: ^0.5.16 specifier: ^0.5.16
version: 0.5.16 version: 0.5.16
axios:
specifier: ^1.13.6
version: 1.14.0
cli-color: cli-color:
specifier: ^2.0.4 specifier: ^2.0.4
version: 2.0.4 version: 2.0.4
@ -1434,6 +1431,14 @@ packages:
peerDependencies: peerDependencies:
'@svgr/core': '*' '@svgr/core': '*'
'@tanstack/query-core@5.96.1':
resolution: {integrity: sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==}
'@tanstack/react-query@5.96.1':
resolution: {integrity: sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==}
peerDependencies:
react: ^18 || ^19
'@tanstack/react-table@8.21.3': '@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -3161,12 +3166,6 @@ packages:
react: '>=16.6.0' react: '>=16.6.0'
react-dom: '>=16.6.0' react-dom: '>=16.6.0'
react-virtuoso@4.18.3:
resolution: {integrity: sha512-fLz/peHAx4Eu0DLHurFEEI7Y6n5CqEoxBh04rgJM9yMuOJah2a9zWg/MUOmZLcp7zuWYorXq5+5bf3IRgkNvWg==}
peerDependencies:
react: '>=16 || >=17 || >= 18 || >= 19'
react-dom: '>=16 || >=17 || >= 18 || >=19'
react@19.2.4: react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3345,11 +3344,6 @@ packages:
svg-parser@2.0.4: svg-parser@2.0.4:
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
swr@2.4.1:
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
systemjs@6.15.1: systemjs@6.15.1:
resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==} resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==}
@ -4963,6 +4957,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@tanstack/query-core@5.96.1': {}
'@tanstack/react-query@5.96.1(react@19.2.4)':
dependencies:
'@tanstack/query-core': 5.96.1
react: 19.2.4
'@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@tanstack/table-core': 8.21.3 '@tanstack/table-core': 8.21.3
@ -6868,11 +6869,6 @@ snapshots:
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
react-virtuoso@4.18.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react@19.2.4: {} react@19.2.4: {}
readdirp@4.1.2: {} readdirp@4.1.2: {}
@ -7064,12 +7060,6 @@ snapshots:
svg-parser@2.0.4: {} svg-parser@2.0.4: {}
swr@2.4.1(react@19.2.4):
dependencies:
dequal: 2.0.3
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
systemjs@6.15.1: {} systemjs@6.15.1: {}
tar@7.5.13: tar@7.5.13:

View File

@ -91,7 +91,7 @@ gethostname = "1.1.0"
scopeguard = "1.2.0" scopeguard = "1.2.0"
tauri-plugin-notification = "2.3.3" tauri-plugin-notification = "2.3.3"
tokio-stream = "0.1.18" tokio-stream = "0.1.18"
backoff = { version = "0.4.0", features = ["tokio"] } backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.5.7" tauri-plugin-http = "2.5.7"
console-subscriber = { version = "0.5.0", optional = true } console-subscriber = { version = "0.5.0", optional = true }
tauri-plugin-devtools = { version = "2.0.1" } tauri-plugin-devtools = { version = "2.0.1" }

View File

@ -13,7 +13,7 @@ use crate::{
utils::{dirs, help}, utils::{dirs, help},
}; };
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use backoff::{Error as BackoffError, ExponentialBackoff}; use backon::{ExponentialBuilder, Retryable as _};
use clash_verge_draft::Draft; use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
@ -204,23 +204,21 @@ impl Config {
} }
pub async fn verify_config_initialization() { pub async fn verify_config_initialization() {
let backoff_strategy = ExponentialBackoff { let backoff = ExponentialBuilder::default()
initial_interval: std::time::Duration::from_millis(100), .with_min_delay(std::time::Duration::from_millis(100))
max_interval: std::time::Duration::from_secs(2), .with_max_delay(std::time::Duration::from_secs(2))
max_elapsed_time: Some(std::time::Duration::from_secs(10)), .with_factor(2.0)
multiplier: 2.0, .with_max_times(10);
..Default::default()
};
let operation = || async { if let Err(e) = (|| async {
if Self::runtime().await.latest_arc().config.is_some() { if Self::runtime().await.latest_arc().config.is_some() {
return Ok::<(), BackoffError<anyhow::Error>>(()); return Ok::<(), anyhow::Error>(());
} }
Self::generate().await
Self::generate().await.map_err(BackoffError::transient) })
}; .retry(backoff)
.await
if let Err(e) = backoff::future::retry(backoff_strategy, operation).await { {
logging!(error, Type::Setup, "Config init verification failed: {}", e); logging!(error, Type::Setup, "Config init verification failed: {}", e);
} }
} }

View File

@ -2,6 +2,7 @@ use crate::constants::files::DNS_CONFIG;
use crate::{config::Config, process::AsyncHandler, utils::dirs}; use crate::{config::Config, process::AsyncHandler, utils::dirs};
use anyhow::Error; use anyhow::Error;
use arc_swap::{ArcSwap, ArcSwapOption}; use arc_swap::{ArcSwap, ArcSwapOption};
use backon::{ConstantBuilder, Retryable as _};
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use reqwest_dav::list_cmd::{ListEntity, ListFile}; use reqwest_dav::list_cmd::{ListEntity, ListFile};
@ -166,40 +167,25 @@ impl WebDavClient {
let client = self.get_client(Operation::Upload).await?; let client = self.get_client(Operation::Upload).await?;
let webdav_path: String = format!("{}/{}", dirs::BACKUP_DIR, file_name).into(); let webdav_path: String = format!("{}/{}", dirs::BACKUP_DIR, file_name).into();
// 读取文件并上传,如果失败尝试一次重试
let file_content = fs::read(&file_path).await?; let file_content = fs::read(&file_path).await?;
// 添加超时保护 let backoff = ConstantBuilder::default()
let upload_result = timeout( .with_delay(Duration::from_millis(500))
Duration::from_secs(TIMEOUT_UPLOAD), .with_max_times(1);
client.put(&webdav_path, file_content.clone()),
)
.await;
match upload_result { (|| async {
Err(_) => { timeout(
logging!(warn, Type::Backup, "Warning: Upload timed out, retrying once"); Duration::from_secs(TIMEOUT_UPLOAD),
tokio::time::sleep(Duration::from_millis(500)).await; client.put(&webdav_path, file_content.clone()),
timeout( )
Duration::from_secs(TIMEOUT_UPLOAD), .await??;
client.put(&webdav_path, file_content), Ok::<(), Error>(())
) })
.await??; .retry(backoff)
Ok(()) .notify(|err, dur| {
} logging!(warn, Type::Backup, "Upload failed: {err}, retrying in {dur:?}");
})
Ok(Err(e)) => { .await
logging!(warn, Type::Backup, "Warning: Upload failed, retrying once: {e}");
tokio::time::sleep(Duration::from_millis(500)).await;
timeout(
Duration::from_secs(TIMEOUT_UPLOAD),
client.put(&webdav_path, file_content),
)
.await??;
Ok(())
}
Ok(Ok(_)) => Ok(()),
}
} }
pub async fn download(&self, filename: String, storage_path: PathBuf) -> Result<(), Error> { pub async fn download(&self, filename: String, storage_path: PathBuf) -> Result<(), Error> {

View File

@ -84,7 +84,7 @@ impl CoreManager {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
async fn wait_for_service_if_needed(&self) { async fn wait_for_service_if_needed(&self) {
use crate::{config::Config, constants::timing, core::service}; use crate::{config::Config, constants::timing, core::service};
use backoff::{Error as BackoffError, ExponentialBackoff}; use backon::{ConstantBuilder, Retryable as _};
let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
@ -92,16 +92,12 @@ impl CoreManager {
return; return;
} }
let backoff = ExponentialBackoff { let max_times = timing::SERVICE_WAIT_MAX.as_millis() / timing::SERVICE_WAIT_INTERVAL.as_millis();
initial_interval: timing::SERVICE_WAIT_INTERVAL, let backoff = ConstantBuilder::default()
max_interval: timing::SERVICE_WAIT_INTERVAL, .with_delay(timing::SERVICE_WAIT_INTERVAL)
max_elapsed_time: Some(timing::SERVICE_WAIT_MAX), .with_max_times(max_times as usize);
multiplier: 1.0,
randomization_factor: 0.0,
..Default::default()
};
let operation = || async { let _ = (|| async {
let mut manager = SERVICE_MANAGER.lock().await; let mut manager = SERVICE_MANAGER.lock().await;
if matches!(manager.current(), ServiceStatus::Ready) { if matches!(manager.current(), ServiceStatus::Ready) {
@ -111,19 +107,19 @@ impl CoreManager {
// If the service IPC path is not ready yet, treat it as transient and retry. // If the service IPC path is not ready yet, treat it as transient and retry.
// Running init/refresh too early can mark service state unavailable and break later config reloads. // Running init/refresh too early can mark service state unavailable and break later config reloads.
if !service::is_service_ipc_path_exists() { if !service::is_service_ipc_path_exists() {
return Err(BackoffError::transient(anyhow::anyhow!("Service IPC not ready"))); return Err(anyhow::anyhow!("Service IPC not ready"));
} }
manager.init().await.map_err(BackoffError::transient)?; manager.init().await?;
let _ = manager.refresh().await; let _ = manager.refresh().await;
if matches!(manager.current(), ServiceStatus::Ready) { if matches!(manager.current(), ServiceStatus::Ready) {
Ok(()) Ok(())
} else { } else {
Err(BackoffError::transient(anyhow::anyhow!("Service not ready"))) Err(anyhow::anyhow!("Service not ready"))
} }
}; })
.retry(backoff)
let _ = backoff::future::retry(backoff, operation).await; .await;
} }
} }

View File

@ -4,6 +4,7 @@ use crate::{
utils::dirs, utils::dirs,
}; };
use anyhow::{Context as _, Result, anyhow, bail}; use anyhow::{Context as _, Result, anyhow, bail};
use backon::{ConstantBuilder, Retryable as _};
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_service_ipc::CoreConfig; use clash_verge_service_ipc::CoreConfig;
use compact_str::CompactString; use compact_str::CompactString;
@ -15,7 +16,7 @@ use std::{
process::Command as StdCommand, process::Command as StdCommand,
time::Duration, time::Duration,
}; };
use tokio::{sync::Mutex, time::sleep}; use tokio::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServiceStatus { pub enum ServiceStatus {
@ -441,31 +442,27 @@ pub async fn wait_and_check_service_available(status: &mut ServiceManager) -> Re
async fn wait_for_service_ipc(status: &mut ServiceManager, reason: &str) -> Result<()> { async fn wait_for_service_ipc(status: &mut ServiceManager, reason: &str) -> Result<()> {
status.0 = ServiceStatus::Unavailable(reason.into()); status.0 = ServiceStatus::Unavailable(reason.into());
let config = ServiceManager::config(); let config = ServiceManager::config();
let mut attempts = 0u32;
#[allow(unused_assignments)]
let mut last_err = anyhow!("service not ready");
loop { let backoff = ConstantBuilder::default()
.with_delay(config.retry_delay)
.with_max_times(config.max_retries);
let result = (|| async {
if Path::new(clash_verge_service_ipc::IPC_PATH).exists() { if Path::new(clash_verge_service_ipc::IPC_PATH).exists() {
match clash_verge_service_ipc::connect().await { clash_verge_service_ipc::connect().await?;
Ok(_) => { Ok(())
status.0 = ServiceStatus::Ready;
return Ok(());
}
Err(e) => last_err = e,
}
} else { } else {
last_err = anyhow!("IPC path not ready"); Err(anyhow!("IPC path not ready"))
} }
})
.retry(backoff)
.await;
if attempts >= config.max_retries as u32 { if result.is_ok() {
break; status.0 = ServiceStatus::Ready;
}
attempts += 1;
sleep(config.retry_delay).await;
} }
Err(last_err) result
} }
pub fn is_service_ipc_path_exists() -> bool { pub fn is_service_ipc_path_exists() -> bool {

View File

@ -155,7 +155,13 @@ impl NetworkManager {
if !parsed.username().is_empty() if !parsed.username().is_empty()
&& let Some(pass) = parsed.password() && let Some(pass) = parsed.password()
{ {
let auth_str = format!("{}:{}", parsed.username(), pass); let username = percent_encoding::percent_decode_str(parsed.username())
.decode_utf8_lossy()
.into_owned();
let password = percent_encoding::percent_decode_str(pass)
.decode_utf8_lossy()
.into_owned();
let auth_str = format!("{}:{}", username, password);
let encoded = general_purpose::STANDARD.encode(auth_str); let encoded = general_purpose::STANDARD.encode(auth_str);
extra_headers.insert("Authorization", HeaderValue::from_str(&format!("Basic {}", encoded))?); extra_headers.insert("Authorization", HeaderValue::from_str(&format!("Basic {}", encoded))?);
} }

View File

@ -14,3 +14,4 @@ export { BaseStyledSelect } from './base-styled-select'
export { BaseStyledTextField } from './base-styled-text-field' export { BaseStyledTextField } from './base-styled-text-field'
export { Switch } from './base-switch' export { Switch } from './base-switch'
export { TooltipIcon } from './base-tooltip-icon' export { TooltipIcon } from './base-tooltip-icon'
export { VirtualList, type VirtualListHandle } from './virtual-list'

View File

@ -0,0 +1,97 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import {
CSSProperties,
forwardRef,
ReactNode,
useEffect,
useImperativeHandle,
useRef,
} from 'react'
export interface VirtualListHandle {
scrollToIndex: (
index: number,
options?: {
align?: 'start' | 'center' | 'end' | 'auto'
behavior?: ScrollBehavior
},
) => void
scrollTo: (options: ScrollToOptions) => void
}
interface VirtualListProps {
count: number
estimateSize: number
overscan?: number
getItemKey?: (index: number) => React.Key
renderItem: (index: number) => ReactNode
style?: CSSProperties
footer?: number
onScroll?: (e: Event) => void
}
export const VirtualList = forwardRef<VirtualListHandle, VirtualListProps>(
(
{
count,
estimateSize,
overscan = 5,
getItemKey,
renderItem,
style,
footer,
onScroll,
},
ref,
) => {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
estimateSize: () => estimateSize,
overscan,
getItemKey,
})
useEffect(() => {
const el = parentRef.current
if (!el || !onScroll) return
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll)
}, [onScroll])
useImperativeHandle(ref, () => ({
scrollToIndex: (index, options) =>
virtualizer.scrollToIndex(index, options),
scrollTo: (options) => parentRef.current?.scrollTo(options),
}))
return (
<div ref={parentRef} style={{ ...style, overflow: 'auto' }}>
<div
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
{renderItem(vi.index)}
</div>
))}
{footer != null && <div style={{ height: footer }} />}
</div>
</div>
)
},
)
VirtualList.displayName = 'VirtualList'

View File

@ -5,23 +5,22 @@ import {
VisibilityOutlined, VisibilityOutlined,
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material' import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useEffect } from 'foxact/use-abortable-effect' import { useEffect } from 'foxact/use-abortable-effect'
import { useIntersection } from 'foxact/use-intersection' import { useIntersection } from 'foxact/use-intersection'
import type { XOR } from 'foxts/ts-xor' import type { XOR } from 'foxts/ts-xor'
import { import {
forwardRef,
memo, memo,
useCallback, useCallback,
useState,
useEffectEvent, useEffectEvent,
useMemo, useMemo,
forwardRef, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
import { getIpInfo } from '@/services/api' import { getIpInfo } from '@/services/api'
import { SWR_EXTERNAL_API } from '@/services/config'
import { EnhancedCard } from './enhanced-card' import { EnhancedCard } from './enhanced-card'
@ -78,7 +77,7 @@ type CountDownState = XOR<
const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>( const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
({ children }, ref) => { ({ children }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { mutate } = useIPInfo() const { refetch: mutate } = useIPInfo()
return ( return (
<EnhancedCard <EnhancedCard
@ -117,7 +116,7 @@ export const IpInfoCard = () => {
remainingSeconds: IP_REFRESH_SECONDS, remainingSeconds: IP_REFRESH_SECONDS,
}) })
const { data: ipInfo, error, isLoading, mutate } = useIPInfo() const { data: ipInfo, error, isLoading, refetch: mutate } = useIPInfo()
// function useEffectEvent // function useEffectEvent
const onCountdownTick = useEffectEvent(async () => { const onCountdownTick = useEffectEvent(async () => {
@ -422,5 +421,14 @@ export const IpInfoCard = () => {
} }
function useIPInfo() { function useIPInfo() {
return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, SWR_EXTERNAL_API) return useQuery({
queryKey: [IP_INFO_CACHE_KEY],
queryFn: getIpInfo,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 1,
retryDelay: 30_000,
})
} }

View File

@ -101,7 +101,8 @@ export const SystemInfoCard = () => {
// 检查更新 // 检查更新
const onCheckUpdate = useLockFn(async () => { const onCheckUpdate = useLockFn(async () => {
try { try {
const info = await triggerCheckUpdate() const result = await triggerCheckUpdate()
const info = result.data
if (!info?.available) { if (!info?.available) {
showNotice.success( showNotice.success(
'settings.components.verge.advanced.notifications.latestVersion', 'settings.components.verge.advanced.notifications.latestVersion',

View File

@ -43,9 +43,8 @@ import {
} from 'react' } from 'react'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Virtuoso } from 'react-virtuoso'
import { BaseSearchBox, Switch } from '@/components/base' import { BaseSearchBox, Switch, VirtualList } from '@/components/base'
import { GroupItem } from '@/components/profile/group-item' import { GroupItem } from '@/components/profile/group-item'
import { import {
getNetworkInterfaces, getNetworkInterfaces,
@ -185,6 +184,92 @@ export const GroupsEditorViewer = (props: Props) => {
[appendSeq, match], [appendSeq, match],
) )
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredGroupList[newIndex].name),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, activationConstraint: { distance: 8 },
@ -1002,109 +1087,15 @@ export const GroupsEditorViewer = (props: Props) => {
}} }}
> >
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso <VirtualList
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }} count={
totalCount={
filteredGroupList.length + filteredGroupList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) + (filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0) (filteredAppendSeq.length > 0 ? 1 : 0)
} }
increaseViewportBy={256} estimateSize={56}
itemContent={(index) => { renderItem={renderItem}
const shift = filteredPrependSeq.length > 0 ? 1 : 0 style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (
deleteSeq.includes(filteredGroupList[newIndex].name)
) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredGroupList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="append"
group={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}}
/> />
</List> </List>
</> </>

View File

@ -21,7 +21,6 @@ import { useLockFn } from 'ahooks'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useCallback, useEffect, useReducer, useState } from 'react' import { useCallback, useEffect, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { ConfirmViewer } from '@/components/profile/confirm-viewer' import { ConfirmViewer } from '@/components/profile/confirm-viewer'
import { EditorViewer } from '@/components/profile/editor-viewer' import { EditorViewer } from '@/components/profile/editor-viewer'
@ -53,6 +52,7 @@ interface Props {
selected: boolean selected: boolean
activating: boolean activating: boolean
itemData: IProfileItem itemData: IProfileItem
mutateProfiles: () => Promise<void>
onSelect: (force: boolean) => void onSelect: (force: boolean) => void
onEdit: () => void onEdit: () => void
onSave?: (prev?: string, curr?: string) => void onSave?: (prev?: string, curr?: string) => void
@ -68,6 +68,7 @@ export const ProfileItem = (props: Props) => {
selected, selected,
activating, activating,
itemData, itemData,
mutateProfiles,
onSelect, onSelect,
onEdit, onEdit,
onSave, onSave,
@ -383,7 +384,7 @@ export const ProfileItem = (props: Props) => {
await updateProfile(itemData.uid, payload) await updateProfile(itemData.uid, payload)
// 更新成功,刷新列表 // 更新成功,刷新列表
mutate('getProfiles') void mutateProfiles()
} catch { } catch {
// 更新完全失败(包括后端的回退尝试) // 更新完全失败(包括后端的回退尝试)
// 不需要做处理,后端会通过事件通知系统发送错误 // 不需要做处理,后端会通过事件通知系统发送错误
@ -579,7 +580,7 @@ export const ProfileItem = (props: Props) => {
if (customEvent.detail?.uid === itemData.uid) { if (customEvent.detail?.uid === itemData.uid) {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })) setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }))
// 刷新 profile 数据以获取最新的 updated 时间戳 // 刷新 profile 数据以获取最新的 updated 时间戳
mutate('getProfiles') void mutateProfiles()
// 更新完成后刷新显示 // 更新完成后刷新显示
if (showNextUpdate) { if (showNextUpdate) {
fetchNextUpdateTime() fetchNextUpdateTime()
@ -599,7 +600,13 @@ export const ProfileItem = (props: Props) => {
handleUpdateCompleted, handleUpdateCompleted,
) )
} }
}, [fetchNextUpdateTime, itemData.uid, setLoadingCache, showNextUpdate]) }, [
fetchNextUpdateTime,
itemData.uid,
mutateProfiles,
setLoadingCache,
showNextUpdate,
])
const handleSaveProfileDocument = useLockFn(async () => { const handleSaveProfileDocument = useLockFn(async () => {
const currentValue = profileDocument.value const currentValue = profileDocument.value

View File

@ -35,9 +35,8 @@ import {
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Virtuoso } from 'react-virtuoso'
import { BaseSearchBox } from '@/components/base' import { BaseSearchBox, VirtualList } from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item' import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds' import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
@ -81,6 +80,92 @@ export const ProxiesEditorViewer = (props: Props) => {
[appendSeq, match], [appendSeq, match],
) )
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredProxyList[newIndex].name),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, activationConstraint: { distance: 8 },
@ -366,109 +451,15 @@ export const ProxiesEditorViewer = (props: Props) => {
}} }}
> >
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso <VirtualList
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }} count={
totalCount={
filteredProxyList.length + filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) + (filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0) (filteredAppendSeq.length > 0 ? 1 : 0)
} }
increaseViewportBy={256} estimateSize={56}
itemContent={(index) => { renderItem={renderItem}
const shift = filteredPrependSeq.length > 0 ? 1 : 0 style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (
deleteSeq.includes(filteredProxyList[newIndex].name)
) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}}
/> />
</List> </List>
</> </>

View File

@ -37,9 +37,8 @@ import {
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Virtuoso } from 'react-virtuoso'
import { BaseSearchBox, Switch } from '@/components/base' import { BaseSearchBox, Switch, VirtualList } from '@/components/base'
import { RuleItem } from '@/components/profile/rule-item' import { RuleItem } from '@/components/profile/rule-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds' import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
@ -283,6 +282,87 @@ export const RulesEditorViewer = (props: Props) => {
[appendSeq, match], [appendSeq, match],
) )
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x
})}
>
{filteredPrependSeq.map((item) => {
return (
<RuleItem
key={item}
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
)
} else {
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x
})}
>
{filteredAppendSeq.map((item) => {
return (
<RuleItem
key={item}
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, activationConstraint: { distance: 8 },
@ -672,103 +752,15 @@ export const RulesEditorViewer = (props: Props) => {
}} }}
> >
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso <VirtualList
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }} count={
totalCount={
filteredRuleList.length + filteredRuleList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) + (filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0) (filteredAppendSeq.length > 0 ? 1 : 0)
} }
increaseViewportBy={256} estimateSize={56}
itemContent={(index) => { renderItem={renderItem}
const shift = filteredPrependSeq.length > 0 ? 1 : 0 style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x
})}
>
{filteredPrependSeq.map((item) => {
return (
<RuleItem
key={item}
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v !== item),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredRuleList[newIndex],
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredRuleList[newIndex],
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x
})}
>
{filteredAppendSeq.map((item) => {
return (
<RuleItem
key={item}
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter((v) => v !== item),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}}
/> />
</List> </List>
</> </>

View File

@ -9,10 +9,10 @@ import {
Snackbar, Snackbar,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api' import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api'
import { BaseEmpty } from '@/components/base' import { BaseEmpty } from '@/components/base'
@ -46,8 +46,6 @@ interface ProxyChainItem {
delay?: number delay?: number
} }
const VirtuosoFooter = () => <div style={{ height: '8px' }} />
export const ProxyGroups = (props: Props) => { export const ProxyGroups = (props: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { mode, isChainMode = false, chainConfigData } = props const { mode, isChainMode = false, chainConfigData } = props
@ -129,10 +127,17 @@ export const ProxyGroups = (props: Props) => {
const timeout = verge?.default_latency_timeout || 10000 const timeout = verge?.default_latency_timeout || 10000
const virtuosoRef = useRef<VirtuosoHandle>(null) const parentRef = useRef<HTMLDivElement>(null)
const scrollPositionRef = useRef<Record<string, number>>({}) const scrollPositionRef = useRef<Record<string, number>>({})
const [showScrollTop, setShowScrollTop] = useState(false) const [showScrollTop, setShowScrollTop] = useState(false)
const scrollerRef = useRef<Element | null>(null)
const virtualizer = useVirtualizer({
count: renderList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 15,
getItemKey: (index) => renderList[index]?.key ?? index,
})
// 从 localStorage 恢复滚动位置 // 从 localStorage 恢复滚动位置
useEffect(() => { useEffect(() => {
@ -149,10 +154,9 @@ export const ProxyGroups = (props: Props) => {
if (savedPosition !== undefined) { if (savedPosition !== undefined) {
restoreTimer = setTimeout(() => { restoreTimer = setTimeout(() => {
virtuosoRef.current?.scrollTo({ if (parentRef.current) {
top: savedPosition, parentRef.current.scrollTop = savedPosition
behavior: 'auto', }
})
}, 100) }, 100)
} }
} }
@ -198,7 +202,7 @@ export const ProxyGroups = (props: Props) => {
// 添加和清理滚动事件监听器 // 添加和清理滚动事件监听器
useEffect(() => { useEffect(() => {
const node = scrollerRef.current const node = parentRef.current
if (!node) return if (!node) return
const listener = handleScroll as EventListener const listener = handleScroll as EventListener
@ -213,7 +217,7 @@ export const ProxyGroups = (props: Props) => {
// 滚动到顶部 // 滚动到顶部
const scrollToTop = useCallback(() => { const scrollToTop = useCallback(() => {
virtuosoRef.current?.scrollTo?.({ parentRef.current?.scrollTo?.({
top: 0, top: 0,
behavior: 'smooth', behavior: 'smooth',
}) })
@ -362,11 +366,7 @@ export const ProxyGroups = (props: Props) => {
) )
if (index >= 0) { if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({ virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' })
index,
align: 'center',
behavior: 'smooth',
})
} }
} }
@ -378,14 +378,10 @@ export const ProxyGroups = (props: Props) => {
) )
if (index >= 0) { if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({ virtualizer.scrollToIndex(index, { align: 'start', behavior: 'smooth' })
index,
align: 'start',
behavior: 'smooth',
})
} }
}, },
[renderList], [renderList, virtualizer],
) )
const proxyGroupNames = useMemo(() => { const proxyGroupNames = useMemo(() => {
@ -475,39 +471,49 @@ export const ProxyGroups = (props: Props) => {
</Box> </Box>
)} )}
<Virtuoso <div
ref={virtuosoRef} ref={parentRef}
style={{ style={{
height: height:
mode === 'rule' && proxyGroups.length > 0 mode === 'rule' && proxyGroups.length > 0
? 'calc(100% - 80px)' // 只有标题的高度 ? 'calc(100% - 80px)' // 只有标题的高度
: 'calc(100% - 14px)', : 'calc(100% - 14px)',
overflow: 'auto',
}} }}
totalCount={renderList.length} >
increaseViewportBy={{ top: 200, bottom: 200 }} <div
overscan={150} style={{
defaultItemHeight={56} height: virtualizer.getTotalSize(),
scrollerRef={(ref) => { position: 'relative',
scrollerRef.current = ref as Element }}
}} >
components={{ {virtualizer.getVirtualItems().map((virtualItem) => (
Footer: VirtuosoFooter, <div
}} key={virtualItem.key}
initialScrollTop={scrollPositionRef.current[mode]} data-index={virtualItem.index}
computeItemKey={(index) => renderList[index].key} ref={virtualizer.measureElement}
itemContent={(index) => ( style={{
<ProxyRender position: 'absolute',
key={renderList[index].key} top: 0,
item={renderList[index]} left: 0,
indent={mode === 'rule' || mode === 'script'} width: '100%',
onLocation={handleLocation} transform: `translateY(${virtualItem.start}px)`,
onCheckAll={handleCheckAll} }}
onHeadState={onHeadState} >
onChangeProxy={handleChangeProxy} <ProxyRender
isChainMode={isChainMode} item={renderList[virtualItem.index]}
/> indent={mode === 'rule' || mode === 'script'}
)} onLocation={handleLocation}
/> onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
isChainMode={isChainMode}
/>
</div>
))}
<div style={{ height: 8 }} />
</div>
</div>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} /> <ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</Box> </Box>
@ -603,34 +609,42 @@ export const ProxyGroups = (props: Props) => {
/> />
)} )}
<Virtuoso <div
ref={virtuosoRef} ref={parentRef}
style={{ height: 'calc(100% - 14px)' }} style={{ height: 'calc(100% - 14px)', overflow: 'auto' }}
totalCount={renderList.length} >
increaseViewportBy={{ top: 200, bottom: 200 }} <div
overscan={150} style={{
defaultItemHeight={56} height: virtualizer.getTotalSize(),
scrollerRef={(ref) => { position: 'relative',
scrollerRef.current = ref as Element }}
}} >
components={{ {virtualizer.getVirtualItems().map((virtualItem) => (
Footer: VirtuosoFooter, <div
}} key={virtualItem.key}
// 添加平滑滚动设置 data-index={virtualItem.index}
initialScrollTop={scrollPositionRef.current[mode]} ref={virtualizer.measureElement}
computeItemKey={(index) => renderList[index].key} style={{
itemContent={(index) => ( position: 'absolute',
<ProxyRender top: 0,
key={renderList[index].key} left: 0,
item={renderList[index]} width: '100%',
indent={mode === 'rule' || mode === 'script'} transform: `translateY(${virtualItem.start}px)`,
onLocation={handleLocation} }}
onCheckAll={handleCheckAll} >
onHeadState={onHeadState} <ProxyRender
onChangeProxy={handleChangeProxy} item={renderList[virtualItem.index]}
/> indent={mode === 'rule' || mode === 'script'}
)} onLocation={handleLocation}
/> onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
</div>
))}
<div style={{ height: 8 }} />
</div>
</div>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} /> <ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</div> </div>
) )

View File

@ -15,10 +15,10 @@ import { useLockFn } from 'ahooks'
import type { Ref } from 'react' import type { Ref } from 'react'
import { useImperativeHandle, useState } from 'react' import { useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { closeAllConnections, upgradeCore } from 'tauri-plugin-mihomo-api' import { closeAllConnections, upgradeCore } from 'tauri-plugin-mihomo-api'
import { BaseDialog, DialogRef } from '@/components/base' import { BaseDialog, DialogRef } from '@/components/base'
import { useClash, useClashInfo } from '@/hooks/use-clash'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { changeClashCore, restartCore } from '@/services/cmds' import { changeClashCore, restartCore } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
@ -40,6 +40,8 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation() const { t } = useTranslation()
const { verge, mutateVerge } = useVerge() const { verge, mutateVerge } = useVerge()
const { mutateVersion } = useClash()
const { invalidateClashConfig } = useClashInfo()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [upgrading, setUpgrading] = useState(false) const [upgrading, setUpgrading] = useState(false)
@ -69,8 +71,8 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
mutateVerge() mutateVerge()
setTimeout(async () => { setTimeout(async () => {
mutate('getClashConfig') invalidateClashConfig()
mutate('getVersion') mutateVersion()
setChangingCore(null) setChangingCore(null)
}, 500) }, 500)
} catch (err) { } catch (err) {

View File

@ -22,7 +22,6 @@ import {
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { import {
BaseDialog, BaseDialog,
@ -110,7 +109,8 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const [hostOptions, setHostOptions] = useState<string[]>([]) const [hostOptions, setHostOptions] = useState<string[]>([])
const { clashConfig } = useAppData() const { clashConfig } = useAppData()
const { indicator: isProxyReallyEnabled } = useSystemProxyState() const { indicator: isProxyReallyEnabled, invalidateProxyState } =
useSystemProxyState()
const { const {
enable_system_proxy: enabled, enable_system_proxy: enabled,
@ -166,10 +166,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
await patchVergeConfig({ enable_system_proxy: false }) await patchVergeConfig({ enable_system_proxy: false })
await sleep(200) await sleep(200)
await patchVergeConfig({ enable_system_proxy: true }) await patchVergeConfig({ enable_system_proxy: true })
await Promise.all([ await invalidateProxyState()
mutate('getSystemProxy'),
mutate('getAutotemProxy'),
])
} }
} catch (err) { } catch (err) {
showNotice.error(err) showNotice.error(err)
@ -177,7 +174,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
} }
updateProxy() updateProxy()
}, [clashConfig?.mixedPort, value.pac]) }, [clashConfig?.mixedPort, value.pac, invalidateProxyState])
const { systemProxyAddress } = useAppData() const { systemProxyAddress } = useAppData()
@ -410,10 +407,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
} }
setTimeout(async () => { setTimeout(async () => {
try { try {
await Promise.all([ await invalidateProxyState()
mutate('getSystemProxy'),
mutate('getAutotemProxy'),
])
// 如果需要重置代理且代理当前启用 // 如果需要重置代理且代理当前启用
if (needResetProxy && enabled) { if (needResetProxy && enabled) {
@ -430,10 +424,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
await patchVergeConfig({ enable_system_proxy: false }) await patchVergeConfig({ enable_system_proxy: false })
await new Promise((resolve) => setTimeout(resolve, 50)) await new Promise((resolve) => setTimeout(resolve, 50))
await patchVergeConfig({ enable_system_proxy: true }) await patchVergeConfig({ enable_system_proxy: true })
await Promise.all([ await invalidateProxyState()
mutate('getSystemProxy'),
mutate('getAutotemProxy'),
])
} }
} }
} catch (err) { } catch (err) {

View File

@ -1,6 +1,5 @@
import React, { useRef } from 'react' import React, { useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { DialogRef, Switch, TooltipIcon } from '@/components/base' import { DialogRef, Switch, TooltipIcon } from '@/components/base'
import ProxyControlSwitches from '@/components/shared/proxy-control-switches' import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
@ -62,7 +61,6 @@ const SettingSystem = ({ onError }: Props) => {
// 先触发UI更新立即看到反馈 // 先触发UI更新立即看到反馈
onChangeData({ enable_auto_launch: e }) onChangeData({ enable_auto_launch: e })
await patchVerge({ enable_auto_launch: e }) await patchVerge({ enable_auto_launch: e })
await mutate('getAutoLaunchStatus')
return Promise.resolve() return Promise.resolve()
} catch (error) { } catch (error) {
// 如果出错,恢复原始状态 // 如果出错,恢复原始状态

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import useSWR, { mutate } from 'swr'
import { getVersion } from 'tauri-plugin-mihomo-api' import { getVersion } from 'tauri-plugin-mihomo-api'
import { import {
@ -7,6 +7,12 @@ import {
getRuntimeConfig, getRuntimeConfig,
patchClashConfig, patchClashConfig,
} from '@/services/cmds' } from '@/services/cmds'
import { queryClient } from '@/services/query-client'
type MutateClashUpdater =
| ((old: IConfigData | undefined) => IConfigData | undefined)
| IConfigData
| undefined
const PORT_KEYS = [ const PORT_KEYS = [
'port', 'port',
@ -38,7 +44,7 @@ const validatePortRange = (port: number) => {
if (port < 1000) { if (port < 1000) {
throw new Error('The port should not < 1000') throw new Error('The port should not < 1000')
} }
if (port > 65536) { if (port > 65535) {
throw new Error('The port should not > 65536') throw new Error('The port should not > 65536')
} }
} }
@ -52,16 +58,35 @@ const validatePorts = (patch: ClashInfoPatch) => {
} }
export const useRuntimeConfig = (shouldFetch: boolean = true) => { export const useRuntimeConfig = (shouldFetch: boolean = true) => {
return useSWR(shouldFetch ? 'getRuntimeConfig' : null, getRuntimeConfig) return useQuery({
queryKey: ['getRuntimeConfig'],
queryFn: getRuntimeConfig,
enabled: shouldFetch,
})
} }
export const useClash = () => { export const useClash = () => {
const { data: clash, mutate: mutateClash } = useRuntimeConfig() const { data: clash, refetch } = useRuntimeConfig()
const { data: versionData, mutate: mutateVersion } = useSWR( const { data: versionData, refetch: mutateVersion } = useQuery({
'getVersion', queryKey: ['getVersion'],
getVersion, queryFn: getVersion,
) })
const mutateClash = (updater?: MutateClashUpdater, revalidate?: boolean) => {
if (updater === undefined) {
return refetch()
}
const next =
typeof updater === 'function'
? updater(queryClient.getQueryData<IConfigData>(['getRuntimeConfig']))
: updater
queryClient.setQueryData(['getRuntimeConfig'], next)
if (revalidate !== false) {
return refetch()
}
return Promise.resolve()
}
const patchClash = useLockFn(async (patch: Partial<IConfigData>) => { const patchClash = useLockFn(async (patch: Partial<IConfigData>) => {
await patchClashConfig(patch) await patchClashConfig(patch)
@ -82,10 +107,10 @@ export const useClash = () => {
} }
export const useClashInfo = () => { export const useClashInfo = () => {
const { data: clashInfo, mutate: mutateInfo } = useSWR( const { data: clashInfo, refetch: mutateInfo } = useQuery({
'getClashInfo', queryKey: ['getClashInfo'],
getClashInfo, queryFn: getClashInfo,
) })
const patchInfo = useLockFn(async (patch: ClashInfoPatch) => { const patchInfo = useLockFn(async (patch: ClashInfoPatch) => {
if (!hasClashInfoPayload(patch)) return if (!hasClashInfoPayload(patch)) return
@ -94,12 +119,16 @@ export const useClashInfo = () => {
await patchClashConfig(patch) await patchClashConfig(patch)
mutateInfo() mutateInfo()
mutate('getClashConfig') queryClient.invalidateQueries({ queryKey: ['getClashConfig'] })
}) })
const invalidateClashConfig = () =>
queryClient.invalidateQueries({ queryKey: ['getClashConfig'] })
return { return {
clashInfo, clashInfo,
mutateInfo, mutateInfo,
patchInfo, patchInfo,
invalidateClashConfig,
} }
} }

View File

@ -1,4 +1,4 @@
import { mutate } from 'swr' import { useQueryClient } from '@tanstack/react-query'
import { MihomoWebSocket } from 'tauri-plugin-mihomo-api' import { MihomoWebSocket } from 'tauri-plugin-mihomo-api'
import { useMihomoWsSubscription } from './use-mihomo-ws-subscription' import { useMihomoWsSubscription } from './use-mihomo-ws-subscription'
@ -33,7 +33,6 @@ const mergeConnectionSnapshot = (
const nextConnections = payload.connections ?? [] const nextConnections = payload.connections ?? []
const previousActive = previous.activeConnections ?? [] const previousActive = previous.activeConnections ?? []
const nextById = new Map(nextConnections.map((conn) => [conn.id, conn])) const nextById = new Map(nextConnections.map((conn) => [conn.id, conn]))
const newIds = new Set(nextConnections.map((conn) => conn.id))
// Keep surviving connections in their previous relative order to reduce row reshuffle, // Keep surviving connections in their previous relative order to reduce row reshuffle,
// but constrain the array to the incoming snapshot length. // but constrain the array to the incoming snapshot length.
@ -60,10 +59,11 @@ const mergeConnectionSnapshot = (
})) }))
const activeConnections = [...carried, ...newcomers] const activeConnections = [...carried, ...newcomers]
const activeIds = new Set(activeConnections.map((conn) => conn.id))
const closedConnections = trimClosedConnections([ const closedConnections = trimClosedConnections([
...(previous.closedConnections ?? []), ...(previous.closedConnections ?? []),
...previousActive.filter((conn) => !newIds.has(conn.id)), ...previousActive.filter((conn) => !activeIds.has(conn.id)),
]) ])
return { return {
@ -75,6 +75,7 @@ const mergeConnectionSnapshot = (
} }
export const useConnectionData = () => { export const useConnectionData = () => {
const queryClient = useQueryClient()
const { response, refresh, subscriptionCacheKey } = const { response, refresh, subscriptionCacheKey } =
useMihomoWsSubscription<ConnectionMonitorData>({ useMihomoWsSubscription<ConnectionMonitorData>({
storageKey: 'mihomo_connection_date', storageKey: 'mihomo_connection_date',
@ -99,7 +100,7 @@ export const useConnectionData = () => {
const clearClosedConnections = () => { const clearClosedConnections = () => {
if (!subscriptionCacheKey) return if (!subscriptionCacheKey) return
mutate(subscriptionCacheKey, { queryClient.setQueryData<ConnectionMonitorData>([subscriptionCacheKey], {
uploadTotal: response.data?.uploadTotal ?? 0, uploadTotal: response.data?.uploadTotal ?? 0,
downloadTotal: response.data?.downloadTotal ?? 0, downloadTotal: response.data?.downloadTotal ?? 0,
activeConnections: response.data?.activeConnections ?? [], activeConnections: response.data?.activeConnections ?? [],

View File

@ -1,9 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { useMemo } from 'react' import { useMemo } from 'react'
import useSWR from 'swr'
import { downloadIconCache } from '@/services/cmds' import { downloadIconCache } from '@/services/cmds'
import { SWR_DEFAULTS } from '@/services/config'
export interface UseIconCacheOptions { export interface UseIconCacheOptions {
icon?: string | null icon?: string | null
@ -24,17 +23,13 @@ export const useIconCache = ({
const iconValue = icon?.trim() ?? '' const iconValue = icon?.trim() ?? ''
const cacheKeyValue = cacheKey?.trim() ?? '' const cacheKeyValue = cacheKey?.trim() ?? ''
const swrKey = useMemo(() => { const isEnabled = useMemo(() => {
if (!enabled || !iconValue.startsWith('http') || cacheKeyValue === '') { return enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
return null
}
return ['icon-cache', iconValue, cacheKeyValue] as const
}, [enabled, iconValue, cacheKeyValue]) }, [enabled, iconValue, cacheKeyValue])
const { data } = useSWR( const { data } = useQuery({
swrKey, queryKey: ['icon-cache', iconValue, cacheKeyValue],
async () => { queryFn: async () => {
try { try {
const fileName = `${cacheKeyValue}-${getFileNameFromUrl(iconValue)}` const fileName = `${cacheKeyValue}-${getFileNameFromUrl(iconValue)}`
const iconPath = await downloadIconCache(iconValue, fileName) const iconPath = await downloadIconCache(iconValue, fileName)
@ -43,10 +38,15 @@ export const useIconCache = ({
return '' return ''
} }
}, },
SWR_DEFAULTS, enabled: isEnabled,
) refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
gcTime: Infinity,
retry: 2,
})
if (!swrKey) { if (!isEnabled) {
return '' return ''
} }

View File

@ -1,6 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { mutate } from 'swr'
import { MihomoWebSocket, type LogLevel } from 'tauri-plugin-mihomo-api' import { MihomoWebSocket, type LogLevel } from 'tauri-plugin-mihomo-api'
import { getClashLogs } from '@/services/cmds' import { getClashLogs } from '@/services/cmds'
@ -39,6 +39,7 @@ const appendLogs = (
): ILogItem[] => clampLogs([...(current ?? []), ...incoming]) ): ILogItem[] => clampLogs([...(current ?? []), ...incoming])
export const useLogData = () => { export const useLogData = () => {
const queryClient = useQueryClient()
const [clashLog] = useClashLog() const [clashLog] = useClashLog()
const enableLog = clashLog.enable const enableLog = clashLog.enable
const logLevel = clashLog.logLevel const logLevel = clashLog.logLevel
@ -50,7 +51,6 @@ export const useLogData = () => {
storageKey: 'mihomo_logs_date', storageKey: 'mihomo_logs_date',
buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null), buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null),
fallbackData: [], fallbackData: [],
keepPreviousData: true,
connect: () => MihomoWebSocket.connect_logs(logLevel), connect: () => MihomoWebSocket.connect_logs(logLevel),
setupHandlers: ({ next, scheduleReconnect, isMounted }) => { setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
let flushTimer: ReturnType<typeof setTimeout> | null = null let flushTimer: ReturnType<typeof setTimeout> | null = null
@ -91,6 +91,9 @@ export const useLogData = () => {
} }
parsed.time = dayjs().format('MM-DD HH:mm:ss') parsed.time = dayjs().format('MM-DD HH:mm:ss')
buffer.push(parsed) buffer.push(parsed)
if (buffer.length > MAX_LOG_NUM) {
buffer.splice(0, buffer.length - MAX_LOG_NUM)
}
if (!flushTimer) { if (!flushTimer) {
flushTimer = setTimeout(flush, FLUSH_DELAY_MS) flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
} }
@ -133,7 +136,7 @@ export const useLogData = () => {
const refreshGetClashLog = (clear = false) => { const refreshGetClashLog = (clear = false) => {
if (clear) { if (clear) {
if (subscriptionCacheKey) { if (subscriptionCacheKey) {
mutate(subscriptionCacheKey, []) queryClient.setQueryData<ILogItem[]>([subscriptionCacheKey], [])
} }
} else { } else {
refresh() refresh()

View File

@ -1,12 +1,19 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useLocalStorage } from 'foxact/use-local-storage' import { useLocalStorage } from 'foxact/use-local-storage'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { mutate, type MutatorCallback } from 'swr'
import useSWRSubscription from 'swr/subscription'
import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api' import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api'
export const RECONNECT_DELAY_MS = 1000 export const RECONNECT_DELAY_MS = 1000
type NextFn<T> = (error?: any, data?: T | MutatorCallback<T>) => void /**
* Mirrors SWR's MutatorCallback: consumers can pass either a plain value or a
* functional updater `(current?: T) => T`. The functional form is resolved
* against the current cache entry before calling `queryClient.setQueryData`.
*/
type NextFn<T> = (
error?: any,
data?: T | ((current?: T) => T | undefined),
) => void
interface HandlerContext<T> { interface HandlerContext<T> {
next: NextFn<T> next: NextFn<T>
@ -25,7 +32,6 @@ interface UseMihomoWsSubscriptionOptions<T> {
buildSubscriptKey: (date: number) => string | null buildSubscriptKey: (date: number) => string | null
fallbackData: T fallbackData: T
connect: () => Promise<MihomoWebSocket> connect: () => Promise<MihomoWebSocket>
keepPreviousData?: boolean
/** /**
* When > 0, coalesce rapid WebSocket messages by wrapping the `next` * When > 0, coalesce rapid WebSocket messages by wrapping the `next`
* function passed to `setupHandlers`. Only the most recent value is * function passed to `setupHandlers`. Only the most recent value is
@ -46,7 +52,6 @@ export const useMihomoWsSubscription = <T>(
buildSubscriptKey, buildSubscriptKey,
fallbackData, fallbackData,
connect, connect,
keepPreviousData = true,
throttleMs, throttleMs,
setupHandlers, setupHandlers,
} = options } = options
@ -56,148 +61,185 @@ export const useMihomoWsSubscription = <T>(
const subscriptKey = buildSubscriptKey(date) const subscriptKey = buildSubscriptKey(date)
const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null
const queryClient = useQueryClient()
const wsRef = useRef<MihomoWebSocket | null>(null) const wsRef = useRef<MihomoWebSocket | null>(null)
const wsFirstConnectionRef = useRef<boolean>(true) const wsFirstConnectionRef = useRef<boolean>(true)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const response = useSWRSubscription<T, any, string | null>( const resolveNextData = useCallback(
subscriptKey, (
(_key, { next }) => { data: T | ((current?: T) => T | undefined) | undefined,
let isMounted = true cacheKey: string,
): T => {
if (typeof data === 'function') {
const updater = data as (current?: T) => T | undefined
const current = queryClient.getQueryData<T>([cacheKey])
return updater(current) ?? fallbackData
}
return data ?? fallbackData
},
[queryClient, fallbackData],
)
const clearReconnectTimer = () => { const response = useQuery<T>({
if (timeoutRef.current) { queryKey: subscriptionCacheKey
clearTimeout(timeoutRef.current) ? [subscriptionCacheKey]
timeoutRef.current = null : ['$sub$__disabled__'],
queryFn: () =>
queryClient.getQueryData<T>([subscriptionCacheKey!]) ?? fallbackData,
initialData: () =>
queryClient.getQueryData<T>([
subscriptionCacheKey ?? '$sub$__disabled__',
]) ?? fallbackData,
staleTime: Infinity,
gcTime: Infinity,
enabled: subscriptionCacheKey !== null,
})
useEffect(() => {
if (!subscriptionCacheKey) return
let isMounted = true
const clearReconnectTimer = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
const closeSocket = async () => {
if (wsRef.current) {
await wsRef.current.close()
wsRef.current = null
}
}
const scheduleReconnect = async () => {
if (!isMounted) return
clearReconnectTimer()
await closeSocket()
if (!isMounted) return
timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS)
}
let throttleCleanup: (() => void) | undefined
let wrappedNext: NextFn<T>
const baseNext: NextFn<T> = (error, data) => {
if (error !== undefined && error !== null) {
return
}
if (data === undefined) return
const resolved = resolveNextData(data, subscriptionCacheKey)
queryClient.setQueryData<T>([subscriptionCacheKey], resolved)
}
if (throttleMs && throttleMs > 0) {
let pendingData: T | ((current?: T) => T | undefined) | undefined
let hasPending = false
let timerId: ReturnType<typeof setTimeout> | null = null
const flush = () => {
timerId = null
if (hasPending) {
const data = pendingData
pendingData = undefined
hasPending = false
baseNext(undefined, data)
} }
} }
const closeSocket = async () => { wrappedNext = (
if (wsRef.current) { error?: any,
await wsRef.current.close() data?: T | ((current?: T) => T | undefined),
wsRef.current = null ) => {
if (error !== undefined && error !== null) {
baseNext(error, data)
return
}
if (!timerId) {
baseNext(undefined, data)
timerId = setTimeout(flush, throttleMs)
} else {
pendingData = data
hasPending = true
} }
} }
const scheduleReconnect = async () => { throttleCleanup = () => {
if (!isMounted) return if (timerId) {
clearReconnectTimer() clearTimeout(timerId)
await closeSocket()
if (!isMounted) return
timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS)
}
let throttleCleanup: (() => void) | undefined
let wrappedNext: NextFn<T> = next
if (throttleMs && throttleMs > 0) {
let pendingData: T | MutatorCallback<T> | undefined
let hasPending = false
let timerId: ReturnType<typeof setTimeout> | null = null
const flush = () => {
timerId = null timerId = null
if (hasPending) {
const data = pendingData
pendingData = undefined
hasPending = false
next(undefined, data)
}
}
wrappedNext = (error?: any, data?: T | MutatorCallback<T>) => {
if (error !== undefined && error !== null) {
next(error, data)
return
}
if (!timerId) {
next(undefined, data)
timerId = setTimeout(flush, throttleMs)
} else {
pendingData = data
hasPending = true
}
}
throttleCleanup = () => {
if (timerId) {
clearTimeout(timerId)
timerId = null
}
} }
} }
} else {
wrappedNext = baseNext
}
const { const {
handleMessage: handleTextMessage, handleMessage: handleTextMessage,
onConnected, onConnected,
cleanup, cleanup,
} = setupHandlers({ } = setupHandlers({
next: wrappedNext, next: wrappedNext,
scheduleReconnect, scheduleReconnect,
isMounted: () => isMounted, isMounted: () => isMounted,
}) })
const cleanupAll = () => { const cleanupAll = () => {
clearReconnectTimer()
throttleCleanup?.()
cleanup?.()
void closeSocket()
}
const handleMessage = (msg: Message) => {
if (msg.type !== 'Text') return
handleTextMessage(msg.data)
}
async function connectWs() {
try {
const ws_ = await connect()
if (!isMounted) {
await ws_.close()
return
}
wsRef.current = ws_
clearReconnectTimer() clearReconnectTimer()
throttleCleanup?.()
cleanup?.()
void closeSocket()
}
const handleMessage = (msg: Message) => { if (onConnected) {
if (msg.type !== 'Text') return await onConnected(ws_)
handleTextMessage(msg.data)
}
async function connectWs() {
try {
const ws_ = await connect()
if (!isMounted) { if (!isMounted) {
await ws_.close() await ws_.close()
return return
} }
}
wsRef.current = ws_ ws_.addListener(handleMessage)
clearReconnectTimer() } catch (ignoreError) {
if (!wsRef.current && isMounted) {
if (onConnected) { timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS)
await onConnected(ws_)
if (!isMounted) {
await ws_.close()
return
}
}
ws_.addListener(handleMessage)
} catch (ignoreError) {
if (!wsRef.current && isMounted) {
timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS)
}
} }
} }
if (wsFirstConnectionRef.current || !wsRef.current) {
wsFirstConnectionRef.current = false
cleanupAll()
void connectWs()
}
return () => {
isMounted = false
wsFirstConnectionRef.current = true
cleanupAll()
}
},
{
fallbackData,
keepPreviousData,
},
)
useEffect(() => {
if (subscriptionCacheKey) {
mutate(subscriptionCacheKey)
} }
if (wsFirstConnectionRef.current || !wsRef.current) {
wsFirstConnectionRef.current = false
cleanupAll()
void connectWs()
}
return () => {
isMounted = false
wsFirstConnectionRef.current = true
cleanupAll()
}
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps
}, [subscriptionCacheKey]) }, [subscriptionCacheKey])
const refresh = useCallback(() => { const refresh = useCallback(() => {

View File

@ -1,17 +1,20 @@
import useSWR from 'swr' import { useQuery } from '@tanstack/react-query'
import { getNetworkInterfacesInfo } from '@/services/cmds' import { getNetworkInterfacesInfo } from '@/services/cmds'
export const useNetworkInterfaces = () => { export const useNetworkInterfaces = () => {
const { data, error, isLoading, mutate } = useSWR( const {
'getNetworkInterfacesInfo', data,
getNetworkInterfacesInfo, error,
{ isLoading,
revalidateOnFocus: false, refetch: mutate,
revalidateOnReconnect: false, } = useQuery({
fallbackData: [], queryKey: ['getNetworkInterfacesInfo'],
}, queryFn: getNetworkInterfacesInfo,
) refetchOnWindowFocus: false,
refetchOnReconnect: false,
initialData: [],
})
return { return {
networkInterfaces: data || [], networkInterfaces: data || [],

View File

@ -1,4 +1,4 @@
import useSWR, { mutate } from 'swr' import { useQuery } from '@tanstack/react-query'
import { selectNodeForGroup } from 'tauri-plugin-mihomo-api' import { selectNodeForGroup } from 'tauri-plugin-mihomo-api'
import { import {
@ -7,32 +7,37 @@ import {
patchProfile, patchProfile,
patchProfilesConfig, patchProfilesConfig,
} from '@/services/cmds' } from '@/services/cmds'
import { queryClient } from '@/services/query-client'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
export const useProfiles = () => { export const useProfiles = () => {
const { const {
data: profiles, data: profiles,
mutate: mutateProfiles, refetch,
error, error,
isValidating, isFetching: isValidating,
} = useSWR('getProfiles', getProfiles, { } = useQuery({
revalidateOnFocus: false, queryKey: ['getProfiles'],
revalidateOnReconnect: false, queryFn: async () => {
dedupingInterval: 500, // 减少去重时间,提高响应性 const data = await getProfiles()
errorRetryCount: 3,
errorRetryInterval: 1000,
refreshInterval: 0, // 完全由手动控制
onError: (error) => {
console.error('[useProfiles] SWR错误:', error)
},
onSuccess: (data) => {
debugLog( debugLog(
'[useProfiles] 配置数据更新成功,配置数量:', '[useProfiles] 配置数据更新成功,配置数量:',
data?.items?.length || 0, data?.items?.length || 0,
) )
return data
}, },
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: 500,
retry: 3,
retryDelay: 1000,
refetchInterval: false,
}) })
const mutateProfiles = async () => {
await refetch()
}
const patchProfiles = async ( const patchProfiles = async (
value: Partial<IProfilesConfig>, value: Partial<IProfilesConfig>,
signal?: AbortSignal, signal?: AbortSignal,
@ -105,8 +110,14 @@ export const useProfiles = () => {
`[ActivateSelected] 当前profile有 ${selected.length} 个代理选择配置`, `[ActivateSelected] 当前profile有 ${selected.length} 个代理选择配置`,
) )
type SelectedEntry = { name?: string; now?: string }
const selectedMap = Object.fromEntries( const selectedMap = Object.fromEntries(
selected.map((each) => [each.name!, each.now!]), (selected as SelectedEntry[])
.filter(
(each): each is SelectedEntry & { name: string; now: string } =>
each.name != null && each.now != null,
)
.map((each) => [each.name, each.now]),
) )
let hasChange = false let hasChange = false
@ -168,10 +179,10 @@ export const useProfiles = () => {
hasChange = true hasChange = true
try { try {
await selectNodeForGroup(name, savedProxy) await selectNodeForGroup(name, savedProxy)
} catch (error: any) { } catch (error: unknown) {
console.warn( console.warn(
`[ActivateSelected] 切换代理组 ${name} 失败:`, `[ActivateSelected] 切换代理组 ${name} 失败:`,
error.message, error instanceof Error ? error.message : String(error),
) )
} }
} }
@ -187,15 +198,21 @@ export const useProfiles = () => {
debugLog(`[ActivateSelected] 完成代理切换,保存新的选择配置`) debugLog(`[ActivateSelected] 完成代理切换,保存新的选择配置`)
try { try {
await patchProfile(profileData.current!, { selected: newSelected }) await patchProfile(current.uid, { selected: newSelected })
debugLog('[ActivateSelected] 代理选择配置保存成功') debugLog('[ActivateSelected] 代理选择配置保存成功')
await mutate('getProxies', calcuProxies()) queryClient.setQueryData(['getProxies'], await calcuProxies())
} catch (error: any) { } catch (error: unknown) {
console.error('[ActivateSelected] 保存代理选择配置失败:', error.message) console.error(
'[ActivateSelected] 保存代理选择配置失败:',
error instanceof Error ? error.message : String(error),
)
} }
} catch (error: any) { } catch (error: unknown) {
console.error('[ActivateSelected] 处理代理选择失败:', error.message) console.error(
'[ActivateSelected] 处理代理选择失败:',
error instanceof Error ? error.message : String(error),
)
} }
} }

View File

@ -1,18 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { useRef } from 'react' import { useRef } from 'react'
import useSWR, { mutate } from 'swr'
import { closeAllConnections } from 'tauri-plugin-mihomo-api' import { closeAllConnections } from 'tauri-plugin-mihomo-api'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { useAppData } from '@/providers/app-data-context' import { useAppData } from '@/providers/app-data-context'
import { getAutotemProxy } from '@/services/cmds' import { getAutotemProxy } from '@/services/cmds'
import { queryClient } from '@/services/query-client'
// 系统代理状态检测统一逻辑 // 系统代理状态检测统一逻辑
export const useSystemProxyState = () => { export const useSystemProxyState = () => {
const { verge, mutateVerge, patchVerge } = useVerge() const { verge, mutateVerge, patchVerge } = useVerge()
const { sysproxy, clashConfig } = useAppData() const { sysproxy, clashConfig } = useAppData()
const { data: autoproxy } = useSWR('getAutotemProxy', getAutotemProxy, { const { data: autoproxy } = useQuery({
revalidateOnFocus: true, queryKey: ['getAutotemProxy'],
revalidateOnReconnect: true, queryFn: getAutotemProxy,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
}) })
const { const {
@ -41,7 +44,10 @@ export const useSystemProxyState = () => {
const busyRef = useRef(false) const busyRef = useRef(false)
const toggleSystemProxy = async (enabled: boolean) => { const toggleSystemProxy = async (enabled: boolean) => {
mutateVerge({ ...verge, enable_system_proxy: enabled }, false) mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev),
false,
)
pendingRef.current = enabled pendingRef.current = enabled
if (busyRef.current) return if (busyRef.current) return
@ -58,13 +64,23 @@ export const useSystemProxyState = () => {
} }
} finally { } finally {
busyRef.current = false busyRef.current = false
await Promise.all([mutate('getSystemProxy'), mutate('getAutotemProxy')]) await Promise.all([
queryClient.invalidateQueries({ queryKey: ['getSystemProxy'] }),
queryClient.invalidateQueries({ queryKey: ['getAutotemProxy'] }),
])
} }
} }
const invalidateProxyState = () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: ['getSystemProxy'] }),
queryClient.invalidateQueries({ queryKey: ['getAutotemProxy'] }),
])
return { return {
indicator, indicator,
configState: enable_system_proxy ?? false, configState: enable_system_proxy ?? false,
toggleSystemProxy, toggleSystemProxy,
invalidateProxyState,
} }
} }

View File

@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react' import { useQuery } from '@tanstack/react-query'
import useSWR from 'swr' import { useEffect, useRef, useState } from 'react'
import { getRunningMode, isAdmin, isServiceAvailable } from '@/services/cmds' import { getRunningMode, isAdmin, isServiceAvailable } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
@ -18,6 +18,9 @@ const defaultSystemState = {
isServiceOk: false, isServiceOk: false,
} as SystemState } as SystemState
// Grace period for service initialization during startup
const STARTUP_GRACE_MS = 10_000
/** /**
* hook * hook
* *
@ -25,14 +28,20 @@ const defaultSystemState = {
export function useSystemState() { export function useSystemState() {
const { verge, patchVerge } = useVerge() const { verge, patchVerge } = useVerge()
const disablingTunRef = useRef(false) const disablingTunRef = useRef(false)
const [isStartingUp, setIsStartingUp] = useState(true)
useEffect(() => {
const timer = setTimeout(() => setIsStartingUp(false), STARTUP_GRACE_MS)
return () => clearTimeout(timer)
}, [])
const { const {
data: systemState, data: systemState = defaultSystemState,
mutate: mutateSystemState, refetch: mutateSystemState,
isLoading, isLoading,
} = useSWR( } = useQuery({
'getSystemState', queryKey: ['getSystemState'],
async () => { queryFn: async () => {
const [runningMode, isAdminMode, isServiceOk] = await Promise.all([ const [runningMode, isAdminMode, isServiceOk] = await Promise.all([
getRunningMode(), getRunningMode(),
isAdmin(), isAdmin(),
@ -40,12 +49,8 @@ export function useSystemState() {
]) ])
return { runningMode, isAdminMode, isServiceOk } as SystemState return { runningMode, isAdminMode, isServiceOk } as SystemState
}, },
{ refetchInterval: isStartingUp ? 2000 : 30000,
suspense: true, })
refreshInterval: 30000,
fallback: defaultSystemState,
},
)
const isSidecarMode = systemState.runningMode === 'Sidecar' const isSidecarMode = systemState.runningMode === 'Sidecar'
const isServiceMode = systemState.runningMode === 'Service' const isServiceMode = systemState.runningMode === 'Service'
@ -61,7 +66,8 @@ export function useSystemState() {
!disablingTunRef.current && !disablingTunRef.current &&
enable_tun_mode && enable_tun_mode &&
!isTunModeAvailable && !isTunModeAvailable &&
!isLoading !isLoading &&
!isStartingUp
) { ) {
disablingTunRef.current = true disablingTunRef.current = true
patchVerge({ enable_tun_mode: false }) patchVerge({ enable_tun_mode: false })
@ -92,7 +98,7 @@ export function useSystemState() {
disablingTunRef.current = false disablingTunRef.current = false
} }
} }
}, [enable_tun_mode, isTunModeAvailable, patchVerge, isLoading]) }, [enable_tun_mode, isTunModeAvailable, patchVerge, isLoading, isStartingUp])
return { return {
runningMode: systemState.runningMode, runningMode: systemState.runningMode,

View File

@ -1,5 +1,6 @@
import useSWR, { mutate as globalMutate, SWRConfiguration } from 'swr' import { useQuery } from '@tanstack/react-query'
import { queryClient } from '@/services/query-client'
import { checkUpdateSafe } from '@/services/update' import { checkUpdateSafe } from '@/services/update'
import { useVerge } from './use-verge' import { useVerge } from './use-verge'
@ -12,8 +13,6 @@ export interface UpdateInfo {
downloadAndInstall: (onEvent?: any) => Promise<void> downloadAndInstall: (onEvent?: any) => Promise<void>
} }
// --- Last check timestamp (shared via SWR + localStorage) ---
const LAST_CHECK_KEY = 'last_check_update' const LAST_CHECK_KEY = 'last_check_update'
export const readLastCheckTime = (): number | null => { export const readLastCheckTime = (): number | null => {
@ -26,16 +25,13 @@ export const readLastCheckTime = (): number | null => {
export const updateLastCheckTime = (timestamp?: number): number => { export const updateLastCheckTime = (timestamp?: number): number => {
const now = timestamp ?? Date.now() const now = timestamp ?? Date.now()
localStorage.setItem(LAST_CHECK_KEY, now.toString()) localStorage.setItem(LAST_CHECK_KEY, now.toString())
globalMutate(LAST_CHECK_KEY, now, false) queryClient.setQueryData([LAST_CHECK_KEY], now)
return now return now
} }
// --- useUpdate hook --- // --- useUpdate hook ---
export const useUpdate = ( export const useUpdate = (enabled: boolean = true) => {
enabled: boolean = true,
options?: SWRConfiguration,
) => {
const { verge } = useVerge() const { verge } = useVerge()
const { auto_check_update } = verge || {} const { auto_check_update } = verge || {}
@ -46,26 +42,28 @@ export const useUpdate = (
const { const {
data: updateInfo, data: updateInfo,
mutate: checkUpdate, refetch: checkUpdate,
isValidating, isFetching: isValidating,
} = useSWR(shouldCheck ? 'checkUpdate' : null, checkUpdateSafe, { } = useQuery({
errorRetryCount: 2, queryKey: ['checkUpdate'],
revalidateIfStale: false, queryFn: async () => {
revalidateOnFocus: false, const result = await checkUpdateSafe()
focusThrottleInterval: 36e5, // 1 hour
refreshInterval: 24 * 60 * 60 * 1000, // 24 hours
dedupingInterval: 60 * 60 * 1000, // 1 hour
...options,
onSuccess: (...args) => {
updateLastCheckTime() updateLastCheckTime()
options?.onSuccess?.(...args) return result
}, },
enabled: shouldCheck,
retry: 2,
staleTime: 60 * 60 * 1000,
refetchInterval: 24 * 60 * 60 * 1000,
refetchOnWindowFocus: false,
}) })
// Shared last check timestamp // Shared last check timestamp
const { data: lastCheckUpdate } = useSWR(LAST_CHECK_KEY, readLastCheckTime, { const { data: lastCheckUpdate } = useQuery({
revalidateOnFocus: false, queryKey: [LAST_CHECK_KEY],
revalidateOnReconnect: false, queryFn: readLastCheckTime,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}) })
return { return {

View File

@ -1,28 +1,52 @@
import useSWR from 'swr' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { getVergeConfig, patchVergeConfig } from '@/services/cmds' import { getVergeConfig, patchVergeConfig } from '@/services/cmds'
import { getPreloadConfig, setPreloadConfig } from '@/services/preload' import { getPreloadConfig, setPreloadConfig } from '@/services/preload'
export const useVerge = () => { export const useVerge = () => {
const qc = useQueryClient()
const initialVergeConfig = getPreloadConfig() const initialVergeConfig = getPreloadConfig()
const { data: verge, mutate: mutateVerge } = useSWR(
'getVergeConfig', const { data: verge, refetch } = useQuery({
async () => { queryKey: ['getVergeConfig'],
queryFn: async () => {
const config = await getVergeConfig() const config = await getVergeConfig()
setPreloadConfig(config) setPreloadConfig(config)
return config return config
}, },
{ initialData: initialVergeConfig ?? undefined,
fallbackData: initialVergeConfig ?? undefined, staleTime: 5000,
revalidateOnMount: !initialVergeConfig, })
},
)
const patchVerge = async (value: Partial<IVergeConfig>) => { const mutateVerge = (
await patchVergeConfig(value) updaterOrData?:
mutateVerge() | IVergeConfig
| ((prev: IVergeConfig | undefined) => IVergeConfig | undefined)
| undefined,
_revalidate?: boolean,
) => {
if (updaterOrData === undefined) {
void refetch()
return
}
if (typeof updaterOrData === 'function') {
const prev = qc.getQueryData<IVergeConfig>(['getVergeConfig'])
const next = updaterOrData(prev)
qc.setQueryData(['getVergeConfig'], next)
} else {
qc.setQueryData(['getVergeConfig'], updaterOrData)
}
} }
const patchVerge = useCallback(
async (value: Partial<IVergeConfig>) => {
await patchVergeConfig(value)
await refetch()
},
[refetch],
)
return { return {
verge, verge,
mutateVerge, mutateVerge,

View File

@ -2,6 +2,7 @@ import './assets/styles/index.scss'
import './services/monaco' import './services/monaco'
import { ResizeObserver } from '@juggle/resize-observer' import { ResizeObserver } from '@juggle/resize-observer'
import { QueryClientProvider } from '@tanstack/react-query'
import { ComposeContextProvider } from 'foxact/compose-context-provider' import { ComposeContextProvider } from 'foxact/compose-context-provider'
import React from 'react' import React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
@ -18,6 +19,7 @@ import {
resolveThemeMode, resolveThemeMode,
getPreloadConfig, getPreloadConfig,
} from './services/preload' } from './services/preload'
import { queryClient } from './services/query-client'
import { import {
LoadingCacheProvider, LoadingCacheProvider,
ThemeModeProvider, ThemeModeProvider,
@ -50,11 +52,13 @@ const initializeApp = (initialThemeMode: 'light' | 'dark') => {
<React.StrictMode> <React.StrictMode>
<ComposeContextProvider contexts={contexts}> <ComposeContextProvider contexts={contexts}>
<BaseErrorBoundary> <BaseErrorBoundary>
<WindowProvider> <QueryClientProvider client={queryClient}>
<AppDataProvider> <WindowProvider>
<RouterProvider router={router} /> <AppDataProvider>
</AppDataProvider> <RouterProvider router={router} />
</WindowProvider> </AppDataProvider>
</WindowProvider>
</QueryClientProvider>
</BaseErrorBoundary> </BaseErrorBoundary>
</ComposeContextProvider> </ComposeContextProvider>
</React.StrictMode>, </React.StrictMode>,

View File

@ -27,7 +27,6 @@ import type { CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Outlet, useLocation, useNavigate } from 'react-router' import { Outlet, useLocation, useNavigate } from 'react-router'
import { SWRConfig } from 'swr'
import iconDark from '@/assets/image/icon_dark.svg?react' import iconDark from '@/assets/image/icon_dark.svg?react'
import iconLight from '@/assets/image/icon_light.svg?react' import iconLight from '@/assets/image/icon_light.svg?react'
@ -259,249 +258,220 @@ const Layout = () => {
} }
return ( return (
<SWRConfig <ThemeProvider theme={theme}>
value={{ {/* 左侧底部窗口控制按钮 */}
errorRetryCount: 3, <NoticeManager position={verge?.notice_position} />
// TODO remove the 5000ms <div
errorRetryInterval: 5000, style={{
onError: (error, key) => { animation: 'fadeIn 0.5s',
// FIXME the condition should not be handle gllobally WebkitAnimation: 'fadeIn 0.5s',
if (key !== 'getAutotemProxy') { }}
console.error(`SWR Error for ${key}:`, error) />
return <style>
} {`
// FIXME we need a better way to handle the retry when first booting app
const silentKeys = ['getVersion', 'getClashConfig', 'getAutotemProxy']
if (silentKeys.includes(key)) return
console.error(`[SWR Error] Key: ${key}, Error:`, error)
},
dedupingInterval: 2000,
}}
>
<ThemeProvider theme={theme}>
{/* 左侧底部窗口控制按钮 */}
<NoticeManager position={verge?.notice_position} />
<div
style={{
animation: 'fadeIn 0.5s',
WebkitAnimation: 'fadeIn 0.5s',
}}
/>
<style>
{`
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
`} `}
</style> </style>
<Paper <Paper
square square
elevation={0} elevation={0}
className={`${OS} layout${navCollapsed ? ' layout--nav-collapsed' : ''}`} className={`${OS} layout${navCollapsed ? ' layout--nav-collapsed' : ''}`}
style={{ style={{
borderTopLeftRadius: '0px', borderTopLeftRadius: '0px',
borderTopRightRadius: '0px', borderTopRightRadius: '0px',
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
if ( if (
OS === 'windows' && OS === 'windows' &&
!['input', 'textarea'].includes( !['input', 'textarea'].includes(
e.currentTarget.tagName.toLowerCase(), e.currentTarget.tagName.toLowerCase(),
) && ) &&
!e.currentTarget.isContentEditable !e.currentTarget.isContentEditable
) { ) {
e.preventDefault() e.preventDefault()
} }
}} }}
sx={[ sx={[
({ palette }) => ({ bgcolor: palette.background.paper }), ({ palette }) => ({ bgcolor: palette.background.paper }),
OS === 'linux' OS === 'linux'
? { ? {
borderRadius: '8px', borderRadius: '8px',
width: '100vw', width: '100vw',
height: '100vh', height: '100vh',
} }
: {}, : {},
]} ]}
> >
{/* Custom titlebar - rendered only when decorated is false, memoized for performance */} {/* Custom titlebar - rendered only when decorated is false, memoized for performance */}
{customTitlebar} {customTitlebar}
<div className="layout-content"> <div className="layout-content">
<div className="layout-content__left"> <div className="layout-content__left">
<div className="the-logo" data-tauri-drag-region="false"> <div className="the-logo" data-tauri-drag-region="false">
<div <div
data-tauri-drag-region="true" data-tauri-drag-region="true"
style={{ style={{
height: '27px', height: '27px',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
}}
>
<SvgIcon
component={isDark ? iconDark : iconLight}
style={{
height: '36px',
width: '36px',
marginTop: '-3px',
marginRight: '5px',
marginLeft: '-3px',
}}
inheritViewBox
/>
<LogoSvg fill={isDark ? 'white' : 'black'} />
</div>
<UpdateButton className="the-newbtn" />
</div>
{menuUnlocked && (
<Box
sx={(theme) => ({
px: 1.5,
py: 0.75,
mx: 'auto',
mb: 1,
maxWidth: 250,
borderRadius: 1.5,
fontSize: 12,
fontWeight: 600,
textAlign: 'center',
color: theme.palette.warning.contrastText,
bgcolor:
theme.palette.mode === 'light'
? theme.palette.warning.main
: theme.palette.warning.dark,
})}
>
{t('layout.components.navigation.menu.reorderMode')}
</Box>
)}
{menuUnlocked ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleMenuDragEnd}
>
<SortableContext items={menuOrder}>
<List
className="the-menu"
onContextMenu={handleMenuContextMenu}
>
{menuOrder.map((path) => {
const item = navItemMap.get(path)
if (!item) {
return null
}
return (
<SortableNavMenuItem
key={item.path}
item={item}
label={t(item.label)}
/>
)
})}
</List>
</SortableContext>
</DndContext>
) : (
<List
className="the-menu"
onContextMenu={handleMenuContextMenu}
>
{menuOrder.map((path) => {
const item = navItemMap.get(path)
if (!item) {
return null
}
return (
<LayoutItem
key={item.path}
to={item.path}
icon={item.icon}
>
{t(item.label)}
</LayoutItem>
)
})}
</List>
)}
<Menu
open={Boolean(menuContextPosition)}
onClose={handleMenuContextClose}
anchorReference="anchorPosition"
anchorPosition={
menuContextPosition
? {
top: menuContextPosition.top,
left: menuContextPosition.left,
}
: undefined
}
transitionDuration={200}
slotProps={{
list: {
sx: { py: 0.5 },
},
}} }}
> >
<MenuItem onClick={handleToggleNavCollapsed} dense> <SvgIcon
{navCollapsed component={isDark ? iconDark : iconLight}
? t('layout.components.navigation.menu.expandNavBar') style={{
: t('layout.components.navigation.menu.collapseNavBar')} height: '36px',
</MenuItem> width: '36px',
<MenuItem marginTop: '-3px',
onClick={menuUnlocked ? handleLockMenu : handleUnlockMenu} marginRight: '5px',
dense marginLeft: '-3px',
> }}
{menuUnlocked inheritViewBox
? t('layout.components.navigation.menu.lock') />
: t('layout.components.navigation.menu.unlock')} <LogoSvg fill={isDark ? 'white' : 'black'} />
</MenuItem>
<MenuItem
onClick={handleResetMenuOrder}
dense
disabled={isDefaultOrder}
>
{t('layout.components.navigation.menu.restoreDefaultOrder')}
</MenuItem>
</Menu>
<div className="the-traffic">
<LayoutTraffic />
</div> </div>
<UpdateButton className="the-newbtn" />
</div> </div>
<div className="layout-content__right"> {menuUnlocked && (
<div className="the-bar"></div> <Box
<div className="the-content"> sx={(theme) => ({
<BaseErrorBoundary> px: 1.5,
<Outlet /> py: 0.75,
</BaseErrorBoundary> mx: 'auto',
{logsPageMountedRef.current && ( mb: 1,
<div maxWidth: 250,
style={{ borderRadius: 1.5,
position: 'absolute', fontSize: 12,
top: 0, fontWeight: 600,
left: 0, textAlign: 'center',
right: 0, color: theme.palette.warning.contrastText,
bottom: 0, bgcolor:
display: isLogsPage ? undefined : 'none', theme.palette.mode === 'light'
}} ? theme.palette.warning.main
: theme.palette.warning.dark,
})}
>
{t('layout.components.navigation.menu.reorderMode')}
</Box>
)}
{menuUnlocked ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleMenuDragEnd}
>
<SortableContext items={menuOrder}>
<List
className="the-menu"
onContextMenu={handleMenuContextMenu}
> >
<LogsPage /> {menuOrder.map((path) => {
</div> const item = navItemMap.get(path)
)} if (!item) {
</div> return null
}
return (
<SortableNavMenuItem
key={item.path}
item={item}
label={t(item.label)}
/>
)
})}
</List>
</SortableContext>
</DndContext>
) : (
<List className="the-menu" onContextMenu={handleMenuContextMenu}>
{menuOrder.map((path) => {
const item = navItemMap.get(path)
if (!item) {
return null
}
return (
<LayoutItem key={item.path} to={item.path} icon={item.icon}>
{t(item.label)}
</LayoutItem>
)
})}
</List>
)}
<Menu
open={Boolean(menuContextPosition)}
onClose={handleMenuContextClose}
anchorReference="anchorPosition"
anchorPosition={
menuContextPosition
? {
top: menuContextPosition.top,
left: menuContextPosition.left,
}
: undefined
}
transitionDuration={200}
slotProps={{
list: {
sx: { py: 0.5 },
},
}}
>
<MenuItem onClick={handleToggleNavCollapsed} dense>
{navCollapsed
? t('layout.components.navigation.menu.expandNavBar')
: t('layout.components.navigation.menu.collapseNavBar')}
</MenuItem>
<MenuItem
onClick={menuUnlocked ? handleLockMenu : handleUnlockMenu}
dense
>
{menuUnlocked
? t('layout.components.navigation.menu.lock')
: t('layout.components.navigation.menu.unlock')}
</MenuItem>
<MenuItem
onClick={handleResetMenuOrder}
dense
disabled={isDefaultOrder}
>
{t('layout.components.navigation.menu.restoreDefaultOrder')}
</MenuItem>
</Menu>
<div className="the-traffic">
<LayoutTraffic />
</div> </div>
</div> </div>
</Paper>
</ThemeProvider> <div className="layout-content__right">
</SWRConfig> <div className="the-bar"></div>
<div className="the-content">
<BaseErrorBoundary>
<Outlet />
</BaseErrorBoundary>
{logsPageMountedRef.current && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: isLogsPage ? undefined : 'none',
}}
>
<LogsPage />
</div>
)}
</div>
</div>
</div>
</Paper>
</ThemeProvider>
) )
} }

View File

@ -1,9 +1,9 @@
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useEffect } from 'react' import { useEffect } from 'react'
import { mutate } from 'swr'
import { useListen } from '@/hooks/use-listen' import { useListen } from '@/hooks/use-listen'
import { queryClient } from '@/services/query-client'
export const useLayoutEvents = ( export const useLayoutEvents = (
handleNotice: (payload: [string, string]) => void, handleNotice: (payload: [string, string]) => void,
@ -14,8 +14,9 @@ export const useLayoutEvents = (
const unlisteners: Array<() => void> = [] const unlisteners: Array<() => void> = []
let disposed = false let disposed = false
const revalidateKeys = (keys: readonly string[]) => { const revalidateKeys = (keys: readonly string[]) => {
const keySet = new Set(keys) keys.forEach((key) => {
mutate((key) => typeof key === 'string' && keySet.has(key)) queryClient.invalidateQueries({ queryKey: [key] })
})
} }
const register = ( const register = (
@ -61,6 +62,7 @@ export const useLayoutEvents = (
'getAutotemProxy', 'getAutotemProxy',
'getRunningMode', 'getRunningMode',
'isServiceAvailable', 'isServiceAvailable',
'getSystemState',
]) ])
}), }),
) )

View File

@ -15,7 +15,6 @@ import {
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Virtuoso } from 'react-virtuoso'
import { closeAllConnections } from 'tauri-plugin-mihomo-api' import { closeAllConnections } from 'tauri-plugin-mihomo-api'
import { import {
@ -23,6 +22,7 @@ import {
BasePage, BasePage,
BaseSearchBox, BaseSearchBox,
BaseStyledSelect, BaseStyledSelect,
VirtualList,
} from '@/components/base' } from '@/components/base'
import { import {
ConnectionDetail, ConnectionDetail,
@ -243,23 +243,27 @@ const ConnectionsPage = () => {
onCloseColumnManager={() => setIsColumnManagerOpen(false)} onCloseColumnManager={() => setIsColumnManagerOpen(false)}
/> />
) : ( ) : (
<Virtuoso <VirtualList
count={filterConn.length}
estimateSize={56}
renderItem={(i) => (
<ConnectionItem
value={filterConn[i]}
closed={connectionsType === 'closed'}
onShowDetail={() =>
detailRef.current?.open(
filterConn[i],
connectionsType === 'closed',
)
}
/>
)}
style={{ style={{
flex: 1, flex: 1,
borderRadius: '8px', borderRadius: '8px',
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
overscrollBehavior: 'contain', overscrollBehavior: 'contain',
}} }}
data={filterConn}
itemContent={(_, item) => (
<ConnectionItem
value={item}
closed={connectionsType === 'closed'}
onShowDetail={() =>
detailRef.current?.open(item, connectionsType === 'closed')
}
/>
)}
/> />
)} )}
<ConnectionDetail ref={detailRef} /> <ConnectionDetail ref={detailRef} />

View File

@ -4,9 +4,8 @@ import {
SwapVertRounded, SwapVertRounded,
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Button, IconButton, MenuItem } from '@mui/material' import { Box, Button, IconButton, MenuItem } from '@mui/material'
import { useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Virtuoso } from 'react-virtuoso'
import { import {
BaseEmpty, BaseEmpty,
@ -14,6 +13,8 @@ import {
BaseSearchBox, BaseSearchBox,
BaseStyledSelect, BaseStyledSelect,
type SearchState, type SearchState,
VirtualList,
type VirtualListHandle,
} from '@/components/base' } from '@/components/base'
import LogItem from '@/components/log/log-item' import LogItem from '@/components/log/log-item'
import { useClashLog } from '@/hooks/use-clash-log' import { useClashLog } from '@/hooks/use-clash-log'
@ -60,6 +61,16 @@ const LogPage = () => {
[filterLogs, isDescending], [filterLogs, isDescending],
) )
const virtuosoRef = useRef<VirtualListHandle>(null)
useEffect(() => {
if (!isDescending && filteredLogs.length > 0) {
virtuosoRef.current?.scrollToIndex(filteredLogs.length - 1, {
behavior: 'smooth',
})
}
}, [filteredLogs.length, isDescending])
const handleLogLevelChange = (newLevel: string) => { const handleLogLevelChange = (newLevel: string) => {
setClashLog((pre: any) => ({ ...pre, logFilter: newLevel })) setClashLog((pre: any) => ({ ...pre, logFilter: newLevel }))
} }
@ -170,16 +181,14 @@ const LogPage = () => {
</Box> </Box>
{filteredLogs.length > 0 ? ( {filteredLogs.length > 0 ? (
<Virtuoso <VirtualList
initialTopMostItemIndex={isDescending ? 0 : 999} ref={virtuosoRef}
data={filteredLogs} count={filteredLogs.length}
style={{ estimateSize={50}
flex: 1, renderItem={(i) => (
}} <LogItem value={filteredLogs[i]} searchState={searchState} />
itemContent={(index, item) => (
<LogItem value={item} searchState={searchState} />
)} )}
followOutput={isDescending ? false : 'smooth'} style={{ flex: 1 }}
/> />
) : ( ) : (
<BaseEmpty /> <BaseEmpty />

View File

@ -22,15 +22,22 @@ import {
} from '@mui/icons-material' } from '@mui/icons-material'
import { LoadingButton } from '@mui/lab' import { LoadingButton } from '@mui/lab'
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material' import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { listen, TauriEvent } from '@tauri-apps/api/event' import { listen, TauriEvent } from '@tauri-apps/api/event'
import { readText } from '@tauri-apps/plugin-clipboard-manager' import { readText } from '@tauri-apps/plugin-clipboard-manager'
import { readTextFile } from '@tauri-apps/plugin-fs' import { readTextFile } from '@tauri-apps/plugin-fs'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { throttle } from 'lodash-es' import { throttle } from 'lodash-es'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router' import { useLocation } from 'react-router'
import useSWR, { mutate } from 'swr'
import { closeAllConnections } from 'tauri-plugin-mihomo-api' import { closeAllConnections } from 'tauri-plugin-mihomo-api'
import { BasePage, BaseStyledTextField, DialogRef } from '@/components/base' import { BasePage, BaseStyledTextField, DialogRef } from '@/components/base'
@ -55,6 +62,7 @@ import {
updateProfile, updateProfile,
} from '@/services/cmds' } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { queryClient } from '@/services/query-client'
import { useSetLoadingCache, useThemeMode } from '@/services/states' import { useSetLoadingCache, useThemeMode } from '@/services/states'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
@ -67,7 +75,7 @@ const debugProfileSwitch = (action: string, profile: string, extra?: any) => {
// 检查请求是否已过期 // 检查请求是否已过期
const isRequestOutdated = ( const isRequestOutdated = (
currentSequence: number, currentSequence: number,
requestSequenceRef: any, requestSequenceRef: RefObject<number>,
profile: string, profile: string,
) => { ) => {
if (currentSequence !== requestSequenceRef.current) { if (currentSequence !== requestSequenceRef.current) {
@ -222,14 +230,14 @@ const ProfilePage = () => {
debugLog('[紧急刷新] 开始强制刷新所有数据') debugLog('[紧急刷新] 开始强制刷新所有数据')
try { try {
// 清除所有SWR缓存 // 只失效 profiles 相关 query不影响 WS 订阅、IP 缓存等其他 query
await mutate(() => true, undefined, { revalidate: false }) await Promise.all([
queryClient.invalidateQueries({ queryKey: ['getProfiles'] }),
queryClient.invalidateQueries({ queryKey: ['getRuntimeLogs'] }),
])
// 强制重新获取配置数据 // 强制重新获取配置数据
await mutateProfiles(undefined, { await mutateProfiles()
revalidate: true,
rollbackOnError: false,
})
// 等待状态稳定后增强配置 // 等待状态稳定后增强配置
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
@ -249,10 +257,10 @@ const ProfilePage = () => {
} }
}) })
const { data: chainLogs = {}, mutate: mutateLogs } = useSWR( const { data: chainLogs = {}, refetch: mutateLogs } = useQuery({
'getRuntimeLogs', queryKey: ['getRuntimeLogs'],
getRuntimeLogs, queryFn: getRuntimeLogs,
) })
const viewerRef = useRef<ProfileViewerRef>(null) const viewerRef = useRef<ProfileViewerRef>(null)
const configRef = useRef<DialogRef>(null) const configRef = useRef<DialogRef>(null)
@ -316,9 +324,10 @@ const ProfilePage = () => {
} }
// 强化的刷新策略 // 强化的刷新策略
// maxRetries 设为 1useProfiles 内部 useQuery 已配置 retry:3业务层只需 1 次额外重试
const performRobustRefresh = async () => { const performRobustRefresh = async () => {
let retryCount = 0 let retryCount = 0
const maxRetries = 5 const maxRetries = 1
const baseDelay = 200 const baseDelay = 200
while (retryCount < maxRetries) { while (retryCount < maxRetries) {
@ -326,10 +335,7 @@ const ProfilePage = () => {
debugLog(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`) debugLog(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`)
// 强制刷新,绕过所有缓存 // 强制刷新,绕过所有缓存
await mutateProfiles(undefined, { await mutateProfiles()
revalidate: true,
rollbackOnError: false,
})
// 等待状态稳定 // 等待状态稳定
await new Promise((resolve) => await new Promise((resolve) =>
@ -350,8 +356,11 @@ const ProfilePage = () => {
// 所有重试失败后的最后尝试 // 所有重试失败后的最后尝试
console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`) console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`)
try { try {
// 清除SWR缓存并重新获取 // 清除缓存并重新获取
await mutate('getProfiles', getProfiles(), { revalidate: true }) await queryClient.fetchQuery({
queryKey: ['getProfiles'],
queryFn: getProfiles,
})
await onEnhance(false) await onEnhance(false)
showNotice.error( showNotice.error(
'profiles.page.feedback.notifications.importNeedsRefresh', 'profiles.page.feedback.notifications.importNeedsRefresh',
@ -1007,6 +1016,7 @@ const ProfilePage = () => {
selected={profiles.current === item.uid} selected={profiles.current === item.uid}
activating={activatings.includes(item.uid)} activating={activatings.includes(item.uid)}
itemData={item} itemData={item}
mutateProfiles={mutateProfiles}
onSelect={(f) => onSelect(item.uid, f)} onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)} onEdit={() => viewerRef.current?.edit(item)}
onSave={async (prev, curr) => { onSave={async (prev, curr) => {

View File

@ -1,9 +1,14 @@
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
import { BaseEmpty, BasePage, BaseSearchBox } from '@/components/base' import {
BaseEmpty,
BasePage,
BaseSearchBox,
VirtualList,
type VirtualListHandle,
} from '@/components/base'
import { ScrollTopButton } from '@/components/layout/scroll-top-button' import { ScrollTopButton } from '@/components/layout/scroll-top-button'
import { ProviderButton } from '@/components/rule/provider-button' import { ProviderButton } from '@/components/rule/provider-button'
import RuleItem from '@/components/rule/rule-item' import RuleItem from '@/components/rule/rule-item'
@ -14,7 +19,7 @@ const RulesPage = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { rules = [], refreshRules, refreshRuleProviders } = useAppData() const { rules = [], refreshRules, refreshRuleProviders } = useAppData()
const [match, setMatch] = useState(() => (_: string) => true) const [match, setMatch] = useState(() => (_: string) => true)
const virtuosoRef = useRef<VirtuosoHandle>(null) const virtuosoRef = useRef<VirtualListHandle>(null)
const [showScrollTop, setShowScrollTop] = useState(false) const [showScrollTop, setShowScrollTop] = useState(false)
const pageVisible = useVisibility() const pageVisible = useVisibility()
@ -39,15 +44,12 @@ const RulesPage = () => {
return rulesWithLineNo.filter((item) => match(item.payload ?? '')) return rulesWithLineNo.filter((item) => match(item.payload ?? ''))
}, [rules, match]) }, [rules, match])
const scrollToTop = () => { const handleScroll = useCallback((e: Event) => {
virtuosoRef.current?.scrollTo({ setShowScrollTop((e.target as HTMLElement).scrollTop > 100)
top: 0, }, [])
behavior: 'smooth',
})
}
const handleScroll = (e: any) => { const scrollToTop = () => {
setShowScrollTop(e.target.scrollTop > 100) virtuosoRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
} }
return ( return (
@ -81,17 +83,13 @@ const RulesPage = () => {
{filteredRules && filteredRules.length > 0 ? ( {filteredRules && filteredRules.length > 0 ? (
<> <>
<Virtuoso <VirtualList
ref={virtuosoRef} ref={virtuosoRef}
data={filteredRules} count={filteredRules.length}
style={{ estimateSize={40}
flex: 1, renderItem={(i) => <RuleItem value={filteredRules[i]} />}
}} style={{ flex: 1 }}
itemContent={(_index, item) => <RuleItem value={item} />} onScroll={handleScroll}
followOutput={'smooth'}
scrollerRef={(ref) => {
if (ref) ref.addEventListener('scroll', handleScroll)
}}
/> />
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} /> <ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
</> </>

View File

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import React, { useCallback, useEffect, useMemo } from 'react' import React, { useCallback, useEffect, useMemo } from 'react'
import useSWR from 'swr'
import { import {
getBaseConfig, getBaseConfig,
getRuleProviders, getRuleProviders,
@ -15,10 +15,24 @@ import {
getRunningMode, getRunningMode,
getSystemProxy, getSystemProxy,
} from '@/services/cmds' } from '@/services/cmds'
import { SWR_DEFAULTS, SWR_MIHOMO } from '@/services/config'
import { AppDataContext, AppDataContextType } from './app-data-context' import { AppDataContext, AppDataContextType } from './app-data-context'
const TQ_MIHOMO = {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: 1500,
retry: 3,
retryDelay: 2000,
} as const
const TQ_DEFAULTS = {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: 5000,
retry: 2,
} as const
// 全局数据提供者组件 // 全局数据提供者组件
export const AppDataProvider = ({ export const AppDataProvider = ({
children, children,
@ -27,35 +41,35 @@ export const AppDataProvider = ({
}) => { }) => {
const { verge } = useVerge() const { verge } = useVerge()
const { data: proxiesData, mutate: refreshProxy } = useSWR( const { data: proxiesData, refetch: refreshProxy } = useQuery({
'getProxies', queryKey: ['getProxies'],
calcuProxies, queryFn: calcuProxies,
SWR_MIHOMO, ...TQ_MIHOMO,
) })
const { data: clashConfig, mutate: refreshClashConfig } = useSWR( const { data: clashConfig, refetch: refreshClashConfig } = useQuery({
'getClashConfig', queryKey: ['getClashConfig'],
getBaseConfig, queryFn: getBaseConfig,
SWR_MIHOMO, ...TQ_MIHOMO,
) })
const { data: proxyProviders, mutate: refreshProxyProviders } = useSWR( const { data: proxyProviders, refetch: refreshProxyProviders } = useQuery({
'getProxyProviders', queryKey: ['getProxyProviders'],
calcuProxyProviders, queryFn: calcuProxyProviders,
SWR_MIHOMO, ...TQ_MIHOMO,
) })
const { data: ruleProviders, mutate: refreshRuleProviders } = useSWR( const { data: ruleProviders, refetch: refreshRuleProviders } = useQuery({
'getRuleProviders', queryKey: ['getRuleProviders'],
getRuleProviders, queryFn: getRuleProviders,
SWR_MIHOMO, ...TQ_MIHOMO,
) })
const { data: rulesData, mutate: refreshRules } = useSWR( const { data: rulesData, refetch: refreshRules } = useQuery({
'getRules', queryKey: ['getRules'],
getRules, queryFn: getRules,
SWR_MIHOMO, ...TQ_MIHOMO,
) })
useEffect(() => { useEffect(() => {
let lastProfileId: string | null = null let lastProfileId: string | null = null
@ -79,7 +93,7 @@ export const AppDataProvider = ({
} }
const addWindowListener = (eventName: string, handler: EventListener) => { const addWindowListener = (eventName: string, handler: EventListener) => {
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener // eslint-disable-next-line @eslint-react/web-api-no-leaked-event-listener
window.addEventListener(eventName, handler) window.addEventListener(eventName, handler)
return () => window.removeEventListener(eventName, handler) return () => window.removeEventListener(eventName, handler)
} }
@ -222,22 +236,24 @@ export const AppDataProvider = ({
} }
}, [refreshProxy, refreshClashConfig, refreshRules, refreshRuleProviders]) }, [refreshProxy, refreshClashConfig, refreshRules, refreshRuleProviders])
const { data: sysproxy, mutate: refreshSysproxy } = useSWR( const { data: sysproxy, refetch: refreshSysproxy } = useQuery({
'getSystemProxy', queryKey: ['getSystemProxy'],
getSystemProxy, queryFn: getSystemProxy,
SWR_DEFAULTS, ...TQ_DEFAULTS,
) })
const { data: runningMode } = useSWR( const { data: runningMode } = useQuery({
'getRunningMode', queryKey: ['getRunningMode'],
getRunningMode, queryFn: getRunningMode,
SWR_DEFAULTS, ...TQ_DEFAULTS,
) })
const { data: uptimeData } = useSWR('appUptime', getAppUptime, { const { data: uptimeData } = useQuery({
...SWR_DEFAULTS, queryKey: ['appUptime'],
refreshInterval: 3000, queryFn: getAppUptime,
errorRetryCount: 1, ...TQ_DEFAULTS,
refetchInterval: 3000,
retry: 1,
}) })
// 提供统一的刷新方法 // 提供统一的刷新方法

View File

@ -0,0 +1,12 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 2000,
retry: 3,
retryDelay: 5000,
refetchOnWindowFocus: false,
},
},
})