From 90a92d740e0c4c8fe548d3b6a35f1bcc297b23a5 Mon Sep 17 00:00:00 2001 From: Teng Fu Date: Tue, 30 Jun 2026 18:18:27 +0800 Subject: [PATCH 1/2] Stabilize full network checks --- .../Sources/NetPulse/MetricGuideView.swift | 2 +- NetPulse/Sources/NetPulse/Models.swift | 14 +++++++++-- NetPulse/Sources/NetPulse/ProbeEngine.swift | 12 ++++++--- .../Sources/NetPulse/ProbeResultViews.swift | 6 +++++ .../Tests/NetPulseTests/NetPulseTests.swift | 25 +++++++++++++++++++ README.md | 3 ++- 6 files changed, 55 insertions(+), 7 deletions(-) diff --git a/NetPulse/Sources/NetPulse/MetricGuideView.swift b/NetPulse/Sources/NetPulse/MetricGuideView.swift index 62d4793..7f58f50 100644 --- a/NetPulse/Sources/NetPulse/MetricGuideView.swift +++ b/NetPulse/Sources/NetPulse/MetricGuideView.swift @@ -33,7 +33,7 @@ struct MetricGuideView: View { ) GuideSection( title: "中位、P95、最慢", - detail: "中位数代表典型体验;P95 用于观察尾部延迟;最慢是单次最大耗时。每项仅采样 3 次时,P95 基本等同于最慢的成功采样。" + detail: "中位数代表典型体验,并决定总体性能状态;P95 用于观察尾部延迟;最慢是单次最大耗时。每项仅采样 3 次时,P95 基本等同于最慢的成功采样,因此单次尖峰会标记为“偶发抖动”,不会单独把整个目标判为慢。" ) } diff --git a/NetPulse/Sources/NetPulse/Models.swift b/NetPulse/Sources/NetPulse/Models.swift index df17996..2aa9f75 100644 --- a/NetPulse/Sources/NetPulse/Models.swift +++ b/NetPulse/Sources/NetPulse/Models.swift @@ -443,7 +443,7 @@ struct ProbeResult: Identifiable, Codable, Hashable { if samples.isEmpty { return .idle } if successCount == 0 { return .unavailable } if failureCount > 0 { return .unstable } - return latencyPerformanceRating(for: p95Ms) + return latencyPerformanceRating(for: medianMs) } var status: HealthStatus { switch performanceRating { @@ -456,6 +456,14 @@ struct ProbeResult: Identifiable, Codable, Hashable { var medianMs: Double? { percentile(samples.filter(\.ok).map(\.timings.totalMs), 0.5) } var p95Ms: Double? { percentile(samples.filter(\.ok).map(\.timings.totalMs), 0.95) } var worstMs: Double? { samples.map(\.timings.totalMs).max() } + var hasLatencyOutlier: Bool { + guard failureCount == 0, + let medianMs, + let p95Ms else { + return false + } + return medianMs < 800 && p95Ms >= 800 + } var latestError: String? { samples.first(where: { !$0.ok }).flatMap { [$0.errorPhase, $0.errorDetail].compactMap { $0 }.joined(separator: ": ") @@ -568,7 +576,9 @@ private func networkScore(for result: ProbeResult) -> Double { guard result.successCount > 0 else { return 0 } let reliability = Double(result.successCount) / Double(result.samples.count) - let base = latencyScore(for: result.p95Ms) + let typicalScore = latencyScore(for: result.medianMs) + let tailScore = latencyScore(for: result.p95Ms) + let base = typicalScore * 0.8 + tailScore * 0.2 let failurePenalty = Double(result.failureCount) / Double(result.samples.count) * 18 return max(0, min(100, base * reliability - failurePenalty)) } diff --git a/NetPulse/Sources/NetPulse/ProbeEngine.swift b/NetPulse/Sources/NetPulse/ProbeEngine.swift index f4ebb84..215fd63 100644 --- a/NetPulse/Sources/NetPulse/ProbeEngine.swift +++ b/NetPulse/Sources/NetPulse/ProbeEngine.swift @@ -22,7 +22,8 @@ private final class MetricsDelegate: NSObject, URLSessionTaskDelegate, @unchecke } enum ProbeEngine { - private static let maximumConcurrentTargets = 6 + private static let maximumConcurrentTargets = 3 + private static let targetStartStaggerNanoseconds: UInt64 = 150_000_000 static func run( targets: [ProbeTarget], @@ -44,9 +45,14 @@ enum ProbeEngine { of: ProbeResult.self, returning: [ProbeResult].self ) { group in - for target in batch { + for (offset, target) in batch.enumerated() { group.addTask { - await probe( + if offset > 0 { + try? await Task.sleep( + nanoseconds: UInt64(offset) * targetStartStaggerNanoseconds + ) + } + return await probe( target: target, sampleCount: sampleCount, timeoutSeconds: timeoutSeconds diff --git a/NetPulse/Sources/NetPulse/ProbeResultViews.swift b/NetPulse/Sources/NetPulse/ProbeResultViews.swift index 7aa4c12..aeb3db1 100644 --- a/NetPulse/Sources/NetPulse/ProbeResultViews.swift +++ b/NetPulse/Sources/NetPulse/ProbeResultViews.swift @@ -35,6 +35,12 @@ struct ProbeResultRow: View { .font(.caption) .foregroundStyle(.secondary) PerformanceLabel(rating: result.performanceRating) + if result.hasLatencyOutlier { + Label("偶发抖动", systemImage: "waveform.path.ecg") + .font(.caption2.weight(.medium)) + .foregroundStyle(.orange) + .help("典型响应正常,但本轮出现了一次明显较慢的采样") + } if routeInsight?.isCurrentPathProblematic == true { Label("CDN 路径异常", systemImage: "point.3.connected.trianglepath.dotted") .font(.caption2.weight(.medium)) diff --git a/NetPulse/Tests/NetPulseTests/NetPulseTests.swift b/NetPulse/Tests/NetPulseTests/NetPulseTests.swift index 2c3a50f..dfa811f 100644 --- a/NetPulse/Tests/NetPulseTests/NetPulseTests.swift +++ b/NetPulse/Tests/NetPulseTests/NetPulseTests.swift @@ -562,6 +562,31 @@ final class NetPulseTests: XCTestCase { XCTAssertTrue(result.usesFakeIPAddress) } + func testSingleLatencyOutlierDoesNotDegradeTypicalStatus() { + let target = ProbeTarget( + service: "Test", + name: "Occasional spike", + category: .api, + urlString: "https://example.com", + acceptAnyStatusBelow500: true + ) + let result = ProbeResult( + target: target, + resolvedAddresses: [], + samples: [ + successfulSample(totalMs: 420), + successfulSample(totalMs: 460), + successfulSample(totalMs: 1_900) + ] + ) + + XCTAssertEqual(result.medianMs, 460) + XCTAssertEqual(result.p95Ms, 1_900) + XCTAssertEqual(result.performanceRating, .good) + XCTAssertEqual(result.status, .healthy) + XCTAssertTrue(result.hasLatencyOutlier) + } + func testReplacingSingleTargetResultPreservesRunAndOtherTargets() { let first = makeEmptyResult(name: "First") let second = makeEmptyResult(name: "Second") diff --git a/README.md b/README.md index c823d56..c48c6ba 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ NetPulse 使用热带鱼等水族箱形象表示实时网络体验。主界面 - 并发检测 Google、X、ChatGPT/OpenAI、Grok/xAI 及自定义服务。 - 分别检测文字、图片、视频 CDN 和 API。 - 展示成功率、探测失败率、中位数、P95 和最慢耗时。 +- 使用中位数判断典型体验,并单独标记 P95 尾部延迟造成的偶发抖动。 - 展开查看 DNS、TCP、TLS、首包和请求总耗时。 - 根据历史结果比较同一 CDN 域名的不同解析 IP,识别特定地址或代理节点路径异常。 - 为 CDN 路径异常提供 Shadowrocket 节点切换、临时 Host 映射和 X 域名规则建议。 @@ -129,7 +130,7 @@ open netpulse://dashboard 默认策略: - 每 5 分钟检测一次。 -- 每个目标默认采样 3 次。采样按目标顺序执行,并限制同时检测的目标数量,避免检测本身制造突发连接压力。 +- 每个目标默认采样 3 次。采样按目标顺序执行,整轮最多同时检测 3 个目标并错开启动时间,避免检测本身制造突发连接压力。 - 单次请求超时 5 秒。 - 同类异常 30 分钟内不重复通知。 From 45f7785c7dfdd3fa2a0d509493f11a7acc51551b Mon Sep 17 00:00:00 2001 From: Teng Fu Date: Wed, 1 Jul 2026 11:32:13 +0800 Subject: [PATCH 2/2] Add verifiable DMG release pipeline --- .github/workflows/ci.yml | 9 +++- .github/workflows/release.yml | 11 +++++ README.md | 15 +++++- docs/DISTRIBUTION.md | 42 +++++++++++++++- scripts/verify_release_dmg.sh | 92 +++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 4 deletions(-) create mode 100755 scripts/verify_release_dmg.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4435d0..49e24b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,5 +17,10 @@ jobs: - name: Run tests run: swift test --package-path NetPulse - - name: Build Universal app - run: ./scripts/build_netpulse.sh universal + - name: Build Universal DMG + env: + NETPULSE_VERSION: 0.0.0-ci + run: ./scripts/build_release_dmg.sh universal + + - name: Verify Universal DMG + run: ./scripts/verify_release_dmg.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1a0f2e..d9701bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,8 @@ on: permissions: contents: write + id-token: write + attestations: write jobs: package: @@ -22,6 +24,14 @@ jobs: export NETPULSE_VERSION="${NETPULSE_VERSION#v}" ./scripts/build_release_dmg.sh universal + - name: Verify release artifacts + run: ./scripts/verify_release_dmg.sh + + - name: Attest DMG build provenance + uses: actions/attest@v4 + with: + subject-path: "${{ github.workspace }}/dist/NetPulse-*.dmg" + - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} @@ -31,4 +41,5 @@ jobs: dist/NetPulse-*.dmg.sha256 \ --verify-tag \ --generate-notes \ + --notes "This Universal DMG is ad-hoc signed and is not Apple-notarized. Verify its SHA-256 checksum and GitHub artifact attestation before first launch. Installation and verification: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/docs/DISTRIBUTION.md" \ --title "NetPulse ${GITHUB_REF_NAME#v}" diff --git a/README.md b/README.md index c48c6ba..f09a138 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,17 @@ NetPulse-<版本>-universal.dmg NetPulse-<版本>-universal.dmg.sha256 ``` -Universal DMG 同时支持 Apple Silicon 与 Intel Mac。下载 DMG、双击打开,再把 `NetPulse.app` 拖入“应用程序”即可。 +Universal DMG 同时支持 Apple Silicon 与 Intel Mac。每个 DMG 都附带 SHA-256 校验文件,并由 GitHub Actions 生成 Artifact Attestation,用于验证安装包确实来自本仓库对应的发布工作流和提交。 + +下载后可以执行: + +```bash +shasum -a 256 -c NetPulse-<版本>-universal.dmg.sha256 +gh attestation verify NetPulse-<版本>-universal.dmg \ + --repo futeng/NetPulse +``` + +两项验证通过后,双击打开 DMG,再把 `NetPulse.app` 拖入“应用程序”。 当前项目没有付费 Apple Developer 证书,因此 Release 使用 **ad-hoc 临时签名,未经过 Apple 公证**。首次启动时: @@ -92,6 +102,7 @@ Universal DMG 同时支持 Apple Silicon 与 Intel Mac。下载 DMG、双击打 ```bash ./scripts/build_release_dmg.sh universal +./scripts/verify_release_dmg.sh ``` 构建产物位于 `dist/NetPulse.app`。安装后可从菜单栏打开,也可运行: @@ -195,6 +206,8 @@ git tag v1.0.0 git push origin v1.0.0 ``` +发布工作流会依次检查 SHA-256、DMG 完整性、ad-hoc 签名、Bundle ID 和 `arm64`/`x86_64` 双架构,然后为最终 DMG 生成 GitHub Artifact Attestation。任何检查失败都不会创建 Release。 + ## 数据与隐私 - 配置和历史保存在 `~/Library/Application Support/NetPulse/`,不会写入代码仓库。 diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 6286ae5..cedd633 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -12,6 +12,10 @@ NetPulse--universal.dmg.sha256 ``` The Universal executable contains both `arm64` and `x86_64` slices. +GitHub Actions also creates a signed Artifact Attestation for each DMG. The +attestation binds the DMG digest to the public repository, workflow and commit +that produced it. It supplements the ad-hoc signature; it does not replace +Apple notarization or make Gatekeeper trust the publisher. The stable application identifier is: @@ -52,6 +56,17 @@ Only download releases from the official repository and compare the DMG checksum shasum -a 256 -c NetPulse--universal.dmg.sha256 ``` +Verify the GitHub build provenance with GitHub CLI: + +```bash +gh attestation verify NetPulse--universal.dmg \ + --repo futeng/NetPulse +``` + +The checksum detects a changed or incomplete download. The attestation proves +that the matching DMG was produced by this repository's GitHub Actions +workflow. Both checks should pass before first launch. + ## Build Commands ```bash @@ -69,6 +84,9 @@ shasum -a 256 -c NetPulse--universal.dmg.sha256 # Universal DMG and SHA-256 file ./scripts/build_release_dmg.sh universal + +# Verify checksum, DMG structure, app signature, Bundle ID and architectures +./scripts/verify_release_dmg.sh ``` An Intel Mac running macOS 13 or later can build and run NetPulse directly. Apple Silicon Macs can also cross-compile the Intel slice with the installed macOS SDK. @@ -82,7 +100,29 @@ git tag v1.0.0 git push origin v1.0.0 ``` -The workflow builds both architectures, combines them with `lipo`, creates the DMG and uploads it to GitHub Releases. +The workflow: + +1. Builds both architectures and combines them with `lipo`. +2. Creates the ad-hoc signed Universal DMG and SHA-256 file. +3. Mounts the final DMG and verifies its signature, Bundle ID and architectures. +4. Creates signed GitHub build provenance for the DMG. +5. Publishes the DMG and checksum to GitHub Releases. + +The regular CI workflow also builds and verifies a temporary Universal DMG on +every push and pull request. This catches packaging failures before a release +tag is created. + +The workflow requires these GitHub token permissions: + +```yaml +permissions: + contents: write + id-token: write + attestations: write +``` + +Artifact Attestations are available for this public repository without a paid +GitHub plan. Verification requires network access to GitHub. ## Future Developer ID Distribution diff --git a/scripts/verify_release_dmg.sh b/scripts/verify_release_dmg.sh new file mode 100755 index 0000000..d85302e --- /dev/null +++ b/scripts/verify_release_dmg.sh @@ -0,0 +1,92 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DIST_DIR="$ROOT_DIR/dist" +EXPECTED_BUNDLE_ID="${NETPULSE_BUNDLE_ID:-com.ftpai.futeng.NetPulse}" +EXPECTED_SIGNING_MODE="${NETPULSE_EXPECTED_SIGNING_MODE:-adhoc}" + +if (( $# > 1 )); then + echo "Usage: $0 [path-to-dmg]" >&2 + exit 2 +fi + +if (( $# == 1 )); then + DMG_PATH="${1:A}" +else + DMG_CANDIDATES=("$DIST_DIR"/NetPulse-*-universal.dmg(N)) + if (( ${#DMG_CANDIDATES[@]} != 1 )); then + echo "Expected exactly one Universal DMG in $DIST_DIR." >&2 + exit 2 + fi + DMG_PATH="${DMG_CANDIDATES[1]}" +fi + +CHECKSUM_PATH="$DMG_PATH.sha256" +MOUNT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netpulse-verify.XXXXXX")" +ATTACHED=false + +cleanup() { + if [[ "$ATTACHED" == true ]]; then + hdiutil detach "$MOUNT_DIR" -quiet || + hdiutil detach "$MOUNT_DIR" -force -quiet || + true + fi + rmdir "$MOUNT_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +if [[ ! -f "$DMG_PATH" ]]; then + echo "DMG not found: $DMG_PATH" >&2 + exit 1 +fi + +if [[ ! -f "$CHECKSUM_PATH" ]]; then + echo "Checksum not found: $CHECKSUM_PATH" >&2 + exit 1 +fi + +( + cd "${DMG_PATH:h}" + shasum -a 256 -c "${CHECKSUM_PATH:t}" +) + +hdiutil verify "$DMG_PATH" +hdiutil attach "$DMG_PATH" \ + -readonly \ + -nobrowse \ + -mountpoint "$MOUNT_DIR" \ + -quiet +ATTACHED=true + +APP_PATH="$MOUNT_DIR/NetPulse.app" +BINARY_PATH="$APP_PATH/Contents/MacOS/NetPulse" + +if [[ ! -d "$APP_PATH" || ! -x "$BINARY_PATH" ]]; then + echo "NetPulse.app is missing or incomplete inside the DMG." >&2 + exit 1 +fi + +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +SIGNATURE_DETAILS="$(codesign -dvvv "$APP_PATH" 2>&1)" + +if [[ "$EXPECTED_SIGNING_MODE" == adhoc && + "$SIGNATURE_DETAILS" != *"Signature=adhoc"* ]]; then + echo "Expected an ad-hoc signature, but the DMG contains another signing mode." >&2 + exit 1 +fi + +ACTUAL_BUNDLE_ID="$(/usr/libexec/PlistBuddy \ + -c 'Print :CFBundleIdentifier' \ + "$APP_PATH/Contents/Info.plist")" +if [[ "$ACTUAL_BUNDLE_ID" != "$EXPECTED_BUNDLE_ID" ]]; then + echo "Unexpected Bundle ID: $ACTUAL_BUNDLE_ID" >&2 + exit 1 +fi + +lipo "$BINARY_PATH" -verify_arch arm64 x86_64 + +echo "Verified DMG: $DMG_PATH" +echo "Bundle ID: $ACTUAL_BUNDLE_ID" +echo "Architectures: $(lipo -archs "$BINARY_PATH")" +echo "Signing mode: $EXPECTED_SIGNING_MODE"