Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ on:

permissions:
contents: write
id-token: write
attestations: write

jobs:
package:
Expand All @@ -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 }}
Expand All @@ -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}"
2 changes: 1 addition & 1 deletion NetPulse/Sources/NetPulse/MetricGuideView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ struct MetricGuideView: View {
)
GuideSection(
title: "中位、P95、最慢",
detail: "中位数代表典型体验;P95 用于观察尾部延迟;最慢是单次最大耗时。每项仅采样 3 次时,P95 基本等同于最慢的成功采样。"
detail: "中位数代表典型体验,并决定总体性能状态;P95 用于观察尾部延迟;最慢是单次最大耗时。每项仅采样 3 次时,P95 基本等同于最慢的成功采样,因此单次尖峰会标记为“偶发抖动”,不会单独把整个目标判为慢。"
)
}

Expand Down
14 changes: 12 additions & 2 deletions NetPulse/Sources/NetPulse/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: ": ")
Expand Down Expand Up @@ -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))
}
Expand Down
12 changes: 9 additions & 3 deletions NetPulse/Sources/NetPulse/ProbeEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions NetPulse/Sources/NetPulse/ProbeResultViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
25 changes: 25 additions & 0 deletions NetPulse/Tests/NetPulseTests/NetPulseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ NetPulse 使用热带鱼等水族箱形象表示实时网络体验。主界面
- 并发检测 Google、X、ChatGPT/OpenAI、Grok/xAI 及自定义服务。
- 分别检测文字、图片、视频 CDN 和 API。
- 展示成功率、探测失败率、中位数、P95 和最慢耗时。
- 使用中位数判断典型体验,并单独标记 P95 尾部延迟造成的偶发抖动。
- 展开查看 DNS、TCP、TLS、首包和请求总耗时。
- 根据历史结果比较同一 CDN 域名的不同解析 IP,识别特定地址或代理节点路径异常。
- 为 CDN 路径异常提供 Shadowrocket 节点切换、临时 Host 映射和 X 域名规则建议。
Expand Down Expand Up @@ -55,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 公证**。首次启动时:

Expand Down Expand Up @@ -91,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`。安装后可从菜单栏打开,也可运行:
Expand Down Expand Up @@ -129,7 +141,7 @@ open netpulse://dashboard
默认策略:

- 每 5 分钟检测一次。
- 每个目标默认采样 3 次。采样按目标顺序执行,并限制同时检测的目标数量,避免检测本身制造突发连接压力。
- 每个目标默认采样 3 次。采样按目标顺序执行,整轮最多同时检测 3 个目标并错开启动时间,避免检测本身制造突发连接压力。
- 单次请求超时 5 秒。
- 同类异常 30 分钟内不重复通知。

Expand Down Expand Up @@ -194,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/`,不会写入代码仓库。
Expand Down
42 changes: 41 additions & 1 deletion docs/DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ NetPulse-<version>-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:

Expand Down Expand Up @@ -52,6 +56,17 @@ Only download releases from the official repository and compare the DMG checksum
shasum -a 256 -c NetPulse-<version>-universal.dmg.sha256
```

Verify the GitHub build provenance with GitHub CLI:

```bash
gh attestation verify NetPulse-<version>-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
Expand All @@ -69,6 +84,9 @@ shasum -a 256 -c NetPulse-<version>-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.
Expand All @@ -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

Expand Down
92 changes: 92 additions & 0 deletions scripts/verify_release_dmg.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading