From 77cd49c311c91c54f95ad32dbef58d80902809a0 Mon Sep 17 00:00:00 2001 From: blessdyb Date: Wed, 4 Nov 2026 19:29:30 -0800 Subject: [PATCH] Consolidate operational sources for 0.17.3 Unify collector, device, and coverage health under Sources; move detection and data controls into their task-focused homes; and make CI and CodeQL routing parallel, selective, and fail closed. Co-Authored-By: Claude Code --- .github/workflows/ci.yml | 182 +++++++------- .github/workflows/codeql.yml | 188 +++++++++++---- Flowlight.xcodeproj/project.pbxproj | 12 +- Flowlight/App/AppNavigation.swift | 77 ++++-- Flowlight/App/FlowlightApp.swift | 8 + Flowlight/App/TrafficMonitor.swift | 4 +- Flowlight/Ask/AskController.swift | 7 +- Flowlight/Ask/AskProvider.swift | 2 + Flowlight/Ask/AskQueryRunner.swift | 9 +- Flowlight/Ask/FeatureGuide.swift | 25 +- .../Localization/de.lproj/Localizable.strings | 19 +- .../Localization/en.lproj/Localizable.strings | 19 +- .../Localization/es.lproj/Localizable.strings | 19 +- .../Localization/fr.lproj/Localizable.strings | 19 +- .../Localization/it.lproj/Localizable.strings | 19 +- .../Localization/ja.lproj/Localizable.strings | 19 +- .../Localization/ko.lproj/Localizable.strings | 19 +- .../pt-PT.lproj/Localizable.strings | 19 +- .../zh-Hans.lproj/Localizable.strings | 19 +- .../zh-Hant.lproj/Localizable.strings | 19 +- Flowlight/Storage/TrafficDatabase.swift | 7 +- Flowlight/UI/AlertsView.swift | 88 ++++--- Flowlight/UI/AskView.swift | 20 +- Flowlight/UI/CaptureView.swift | 225 ++++++++++++++---- Flowlight/UI/ContentView.swift | 9 +- Flowlight/UI/CoverageView.swift | 8 - Flowlight/UI/DevicesView.swift | 16 -- Flowlight/UI/LiveView.swift | 2 +- Flowlight/UI/ReportsView.swift | 14 +- Flowlight/UI/RulesView.swift | 6 +- Flowlight/UI/SettingsView.swift | 216 +++++++++++------ FlowlightTests/AskFeatureTests.swift | 17 +- FlowlightTests/AskTests.swift | 3 +- FlowlightTests/VisualizeNavigationTests.swift | 50 +++- README.md | 16 +- docs/404.html | 2 +- docs/DEVELOPMENT.md | 14 +- docs/about/index.html | 2 +- docs/assets/screenshots/agent-detail.png | Bin 608840 -> 654466 bytes docs/assets/screenshots/agent-tools.png | Bin 453424 -> 496951 bytes docs/assets/screenshots/agents.png | Bin 599295 -> 645004 bytes docs/assets/screenshots/alerts.png | Bin 553231 -> 601207 bytes docs/assets/screenshots/always-monitor.png | Bin 687699 -> 750784 bytes docs/assets/screenshots/inspect.png | Bin 656060 -> 697689 bytes docs/assets/screenshots/live.png | Bin 675240 -> 796958 bytes docs/assets/screenshots/manifest.json | 40 ++-- docs/assets/screenshots/mcp-servers.png | Bin 473881 -> 516593 bytes docs/assets/screenshots/reports.png | Bin 506145 -> 760258 bytes docs/assets/screenshots/visualize.png | Bin 832625 -> 858549 bytes docs/de/about/index.html | 2 +- docs/de/docs/index.html | 48 ++-- docs/de/index.html | 32 +-- docs/de/privacy/index.html | 4 +- docs/de/threat-model/index.html | 8 +- docs/docs/index.html | 57 +++-- docs/es/about/index.html | 2 +- docs/es/docs/index.html | 46 ++-- docs/es/index.html | 32 +-- docs/es/privacy/index.html | 4 +- docs/es/threat-model/index.html | 10 +- docs/fr/about/index.html | 2 +- docs/fr/docs/index.html | 53 ++--- docs/fr/index.html | 32 +-- docs/fr/privacy/index.html | 8 +- docs/fr/threat-model/index.html | 8 +- docs/index.html | 34 +-- docs/it/about/index.html | 2 +- docs/it/docs/index.html | 46 ++-- docs/it/index.html | 32 +-- docs/it/privacy/index.html | 4 +- docs/it/threat-model/index.html | 8 +- docs/ja/about/index.html | 2 +- docs/ja/docs/index.html | 50 ++-- docs/ja/index.html | 32 +-- docs/ja/privacy/index.html | 4 +- docs/ja/threat-model/index.html | 8 +- docs/ko/about/index.html | 2 +- docs/ko/docs/index.html | 50 ++-- docs/ko/index.html | 32 +-- docs/ko/privacy/index.html | 4 +- docs/ko/threat-model/index.html | 8 +- docs/llms-full.txt | 95 ++++---- docs/llms.txt | 2 +- docs/plugins/index.html | 2 +- docs/privacy/index.html | 8 +- docs/pt-PT/about/index.html | 2 +- docs/pt-PT/docs/index.html | 46 ++-- docs/pt-PT/index.html | 32 +-- docs/pt-PT/privacy/index.html | 4 +- docs/pt-PT/threat-model/index.html | 8 +- docs/releases/index.html | 14 +- docs/threat-model/index.html | 8 +- docs/zh-Hans/about/index.html | 2 +- docs/zh-Hans/docs/index.html | 48 ++-- docs/zh-Hans/index.html | 32 +-- docs/zh-Hans/privacy/index.html | 4 +- docs/zh-Hans/threat-model/index.html | 8 +- docs/zh-Hant/about/index.html | 2 +- docs/zh-Hant/docs/index.html | 48 ++-- docs/zh-Hant/index.html | 32 +-- docs/zh-Hant/privacy/index.html | 4 +- docs/zh-Hant/threat-model/index.html | 10 +- .../screenshots/macos/01-connections.png | Bin 695121 -> 722896 bytes .../screenshots/macos/02-ai-agents.png | Bin 566193 -> 619491 bytes .../screenshots/macos/03-history.png | Bin 469335 -> 657837 bytes .../app-store/screenshots/macos/04-rules.png | Bin 525903 -> 550114 bytes .../app-store/screenshots/macos/05-live.png | Bin 642650 -> 721843 bytes .../app-store/screenshots/macos/06-alerts.png | Bin 511198 -> 565864 bytes .../screenshots/macos/07-agent-detail.png | Bin 559635 -> 611839 bytes .../screenshots/macos/08-agent-tools.png | Bin 571796 -> 621108 bytes .../screenshots/macos/09-mcp-servers.png | Bin 572204 -> 622275 bytes .../app-store/screenshots/macos/manifest.json | 36 +-- project.yml | 4 +- scripts/check-codeql-gate.sh | 30 +++ scripts/check_release_checks.py | 8 +- scripts/check_site.py | 101 ++++++-- scripts/classify_changes.py | 13 +- scripts/run-ci-tests.sh | 58 +++++ scripts/screenshots.sh | 2 +- scripts/test-codeql-gate.sh | 31 +++ scripts/test_check_site.py | 18 +- scripts/test_classify_changes.py | 33 ++- scripts/test_release_checks.py | 20 ++ site/pages/de/docs.html | 40 ++-- site/pages/de/index.html | 10 +- site/pages/de/privacy.html | 2 +- site/pages/de/threat-model.html | 6 +- site/pages/docs.html | 49 ++-- site/pages/es/docs.html | 38 ++- site/pages/es/index.html | 10 +- site/pages/es/privacy.html | 2 +- site/pages/es/threat-model.html | 8 +- site/pages/fr/docs.html | 45 ++-- site/pages/fr/index.html | 10 +- site/pages/fr/privacy.html | 6 +- site/pages/fr/threat-model.html | 6 +- site/pages/index.html | 10 +- site/pages/it/docs.html | 38 ++- site/pages/it/index.html | 10 +- site/pages/it/privacy.html | 2 +- site/pages/it/threat-model.html | 6 +- site/pages/ja/docs.html | 42 ++-- site/pages/ja/index.html | 10 +- site/pages/ja/privacy.html | 2 +- site/pages/ja/threat-model.html | 6 +- site/pages/ko/docs.html | 42 ++-- site/pages/ko/index.html | 10 +- site/pages/ko/privacy.html | 2 +- site/pages/ko/threat-model.html | 6 +- site/pages/privacy.html | 6 +- site/pages/pt-PT/docs.html | 38 ++- site/pages/pt-PT/index.html | 10 +- site/pages/pt-PT/privacy.html | 2 +- site/pages/pt-PT/threat-model.html | 6 +- site/pages/releases.html | 12 +- site/pages/threat-model.html | 6 +- site/pages/zh-Hans/docs.html | 40 ++-- site/pages/zh-Hans/index.html | 10 +- site/pages/zh-Hans/privacy.html | 2 +- site/pages/zh-Hans/threat-model.html | 6 +- site/pages/zh-Hant/docs.html | 40 ++-- site/pages/zh-Hant/index.html | 10 +- site/pages/zh-Hant/privacy.html | 2 +- site/pages/zh-Hant/threat-model.html | 8 +- 164 files changed, 2041 insertions(+), 1465 deletions(-) create mode 100755 scripts/check-codeql-gate.sh create mode 100755 scripts/run-ci-tests.sh create mode 100755 scripts/test-codeql-gate.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bd1bbe1..301f2cb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: # Seconds of Python, so it runs on everything: the site is generated from Swift sources and project.yml as well # as from site/, and "docs/ is out of date" should be caught whichever of those moved. site: + needs: changes # Pinned rather than `ubuntu-latest`: that label moves to Ubuntu 26 in October 2026, and every run has # been carrying an annotation saying so. A named image means the move happens when we make it, with a # green run to prove it, rather than on a date somebody else chose. macOS is already pinned the same way. @@ -141,17 +142,27 @@ jobs: done <<< "$(sed -n '/^Shipped:/,/^Planned/p' README.md | grep -o '([0-9]\+\.[0-9]\+\.[0-9]\+)' | tr -d '()')" [ "$bad" -eq 0 ] || exit 1 - test: + project-drift: + name: project drift + needs: changes + if: needs.changes.outputs.app == 'true' + runs-on: macos-15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate the project and reject drift + run: scripts/check_xcode_project.sh + + unit-oldest: + name: unit (oldest Xcode) needs: changes if: needs.changes.outputs.app == 'true' runs-on: macos-15 - strategy: - fail-fast: false - matrix: - # README says Xcode 16 or later, so both ends of that are built. The oldest one earns its place: it is - # stricter about expressions the type checker has to explore, and it caught two the newest compiler accepts. - xcode: [oldest, newest] - name: test (${{ matrix.xcode }} Xcode) steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: @@ -160,29 +171,61 @@ jobs: - name: Select Xcode run: | set -euo pipefail - if [[ "${{ matrix.xcode }}" == "newest" ]]; then - sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" - else - sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | head -1)" - fi + sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | head -1)" xcodebuild -version - - name: Install XcodeGen - run: brew install xcodegen + # Ad-hoc signing: the entitlements that need a Developer ID profile are only exercised by the release build. + - name: Test + timeout-minutes: 40 + run: scripts/run-ci-tests.sh - - name: Generate the project and reject drift - run: scripts/check_xcode_project.sh + unit-newest: + name: unit (newest Xcode) + needs: changes + if: needs.changes.outputs.app == 'true' + runs-on: macos-15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Select Xcode + run: | + set -euo pipefail + sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" + xcodebuild -version - name: Check release helpers, catalog policy, and warning gate - if: matrix.xcode == 'newest' run: | scripts/test-notarize.sh scripts/test-rule-pack-policy.sh scripts/test-swift-warnings.sh + scripts/test-codeql-gate.sh python3 -I scripts/test_classify_changes.py python3 -I scripts/test_check_site.py python3 scripts/test_release_checks.py + # Ad-hoc signing: the entitlements that need a Developer ID profile are only exercised by the release build. + - name: Test + timeout-minutes: 40 + run: scripts/run-ci-tests.sh + + store: + name: store + needs: changes + if: needs.changes.outputs.app == 'true' + runs-on: macos-15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Select newest Xcode + run: | + set -euo pipefail + sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" + xcodebuild -version + # This compiles a different product graph and activation path from the Developer ID build. The build clears # protected entitlements because CI has no Store profiles; the next step re-signs the isolated product with the # repository plists so the product gate still inspects the actual executable entitlements it is meant to police. @@ -210,66 +253,6 @@ jobs: codesign --force --sign - --entitlements Flowlight/Flowlight-AppStore.entitlements "$APP" scripts/check_app_store_product.sh "$APP" - # Ad-hoc signing: the entitlements that need a Developer ID profile are only exercised by the release build. - - name: Test - timeout-minutes: 40 - run: | - # GitHub runs this step under `bash -e`, so the first failing command ends it. That killed the whole - # point of the reporting below: a failing test exited 65 with no output at all, and the log saying - # which test failed was thrown away with the runner. `set +e` has to be explicit — the `set` line - # below can't clear a flag the shell was started with. - set +e - set -uo pipefail - # xcodebuild occasionally never returns here — the same suite passes locally and on most runs, and the - # per-test allowance below doesn't always catch it, so the whole invocation gets a watchdog. One retry: - # a hang that clears on a second attempt is a flake, and one that repeats is a real failure worth seeing. - run_tests() { - local log="$1" limit=900 - xcodebuild test -project Flowlight.xcodeproj -scheme Flowlight -destination 'platform=macOS' \ - -test-timeouts-enabled YES \ - -default-test-execution-time-allowance 120 \ - -maximum-test-execution-time-allowance 300 \ - CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= CODE_SIGN_ENTITLEMENTS= > "$log" 2>&1 & - local pid=$! - local waited=0 - while kill -0 "$pid" 2>/dev/null; do - if [ "$waited" -ge "$limit" ]; then - echo "::warning::xcodebuild still running after ${limit}s — killing it." - kill -9 "$pid" 2>/dev/null - pkill -9 -f "xcodebuild|XCTestCore|testmanagerd" 2>/dev/null - wait "$pid" 2>/dev/null - return 124 - fi - sleep 5 - waited=$((waited + 5)) - done - wait "$pid" - } - - LOG="$RUNNER_TEMP/test.log" - run_tests "$LOG" - status=$? - grep -E "Test Case .* (passed|failed)|Executed .* tests|\*\* TEST" "$LOG" | tail -25 - - if [ "$status" -eq 124 ]; then - echo "::group::Where it stopped (last 60 lines before the kill)"; tail -60 "$LOG"; echo "::endgroup::" - echo "Retrying once." - LOG="$RUNNER_TEMP/test-retry.log" - run_tests "$LOG" - status=$? - grep -E "Executed .* tests|\*\* TEST" "$LOG" | tail -10 - fi - - if [ "$status" -ne 0 ]; then - echo "::group::Last 300 lines"; tail -300 "$LOG"; echo "::endgroup::" - echo "Why it failed (status $status):" - # Outside the group, so it is the first thing visible on a failed run. - grep -E "error:|Testing failed:|XCTAssert.*failed|exceeded execution time allowance" "$LOG" | head -40 - exit 1 - fi - set -e - scripts/check-swift-warnings.sh "$LOG" - # Exact-head evidence proves what was reviewed; this separate PR-only build proves that same head still compiles # when combined with the current base. The trees are intentionally different, so this is not duplicate work. merge-compatibility: @@ -293,14 +276,17 @@ jobs: gate: name: Review / gate if: always() - needs: [changes, site, test, merge-compatibility] + needs: [changes, site, project-drift, unit-oldest, unit-newest, store, merge-compatibility] runs-on: ubuntu-24.04 steps: - name: Require every applicable review job env: CHANGES_RESULT: ${{ needs.changes.result }} SITE_RESULT: ${{ needs.site.result }} - TEST_RESULT: ${{ needs.test.result }} + PROJECT_DRIFT_RESULT: ${{ needs.project-drift.result }} + UNIT_OLDEST_RESULT: ${{ needs.unit-oldest.result }} + UNIT_NEWEST_RESULT: ${{ needs.unit-newest.result }} + STORE_RESULT: ${{ needs.store.result }} MERGE_RESULT: ${{ needs.merge-compatibility.result }} EVENT_NAME: ${{ github.event_name }} APP_ROUTE: ${{ needs.changes.outputs.app }} @@ -318,13 +304,21 @@ jobs: echo "::error::Site verification did not succeed: $SITE_RESULT" exit 1 } - case "$APP_ROUTE:$TEST_RESULT" in - true:success|false:skipped) ;; - *) - echo "::error::Application route $APP_ROUTE produced test result $TEST_RESULT." - exit 1 - ;; - esac + for result in \ + "project drift:$PROJECT_DRIFT_RESULT" \ + "oldest unit:$UNIT_OLDEST_RESULT" \ + "newest unit:$UNIT_NEWEST_RESULT" \ + "store:$STORE_RESULT"; do + name=${result%%:*} + conclusion=${result#*:} + case "$APP_ROUTE:$conclusion" in + true:success|false:skipped) ;; + *) + echo "::error::Application route $APP_ROUTE produced $name result $conclusion." + exit 1 + ;; + esac + done if [[ "$EVENT_NAME" == pull_request && "$APP_ROUTE" == true ]]; then [[ "$MERGE_RESULT" == success ]] || { echo "::error::Prospective-merge compatibility did not succeed: $MERGE_RESULT" @@ -336,7 +330,11 @@ jobs: exit 1 } fi - [[ -n "$HEAD_SHA" && -n "$TREE" && -n "$FINGERPRINT" && -n "$SCHEMA" ]] || { + [[ "$SCHEMA" == flowlight-review-v2 ]] || { + echo "::error::Unexpected review receipt schema: $SCHEMA" + exit 1 + } + [[ -n "$HEAD_SHA" && -n "$TREE" && -n "$FINGERPRINT" ]] || { echo "::error::The review receipt identity is incomplete." exit 1 } @@ -351,14 +349,18 @@ jobs: --arg app_route "$APP_ROUTE" \ --arg changes "$CHANGES_RESULT" \ --arg site "$SITE_RESULT" \ - --arg tests "$TEST_RESULT" \ + --arg project_drift "$PROJECT_DRIFT_RESULT" \ + --arg unit_oldest "$UNIT_OLDEST_RESULT" \ + --arg unit_newest "$UNIT_NEWEST_RESULT" \ + --arg store "$STORE_RESULT" \ --arg merge_compatibility "$MERGE_RESULT" \ --arg run_id "${{ github.run_id }}" \ --arg run_attempt "${{ github.run_attempt }}" \ '{schema: $schema, repository: $repository, workflow: $workflow, event: $event, source_sha: $source_sha, source_tree: $source_tree, app_fingerprint: $app_fingerprint, app_route: $app_route, - conclusions: {changes: $changes, site: $site, tests: $tests, + conclusions: {changes: $changes, site: $site, project_drift: $project_drift, + unit_oldest: $unit_oldest, unit_newest: $unit_newest, store: $store, merge_compatibility: $merge_compatibility}, run_id: $run_id, run_attempt: $run_attempt}' \ > "$RUNNER_TEMP/review-receipt.json" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0ae7052a..79be96d3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,10 +1,7 @@ # CodeQL code scanning. # -# This exists as a workflow rather than as GitHub's default setup for one reason: the Xcode project is not -# committed. XcodeGen writes it from project.yml, so `autobuild` looked for a project or workspace, found -# neither, and gave up with `no-project-found`. The reaction to that failure was to drop Swift from the -# languages being scanned, which left the one language worth scanning in a tool that reads other people's -# network traffic unscanned. Swift is built here explicitly instead. +# This exists as a workflow rather than as GitHub's default setup for one reason: Swift must be built explicitly. +# XcodeGen's committed project is prepared before CodeQL tracing begins so Homebrew never runs under Rosetta. # # Requires GitHub's default setup to be turned OFF for this repository (Settings › Code security › Code # scanning), because the two configurations cannot both be active. @@ -27,65 +24,101 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - analyze: - name: analyze (${{ matrix.language }}) - runs-on: ${{ matrix.runner }} + classify: + name: classify + runs-on: ubuntu-24.04 + outputs: + app: ${{ steps.route.outputs.app || 'true' }} + python: ${{ steps.route.outputs.python || 'true' }} + actions: ${{ steps.route.outputs.actions || 'true' }} + steps: + - id: checkout + if: github.event_name == 'push' || github.event_name == 'pull_request' + continue-on-error: true + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + # Code scanning reports against the PR head, so classify that exact commit rather than the merge ref. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + - id: what + name: Classify the exact change + if: steps.checkout.outcome == 'success' + continue-on-error: true + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + python3 -I scripts/classify_changes.py \ + --repo . --base "$BASE_SHA" --head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" \ + > "$RUNNER_TEMP/codeql-classification.json" + cat "$RUNNER_TEMP/codeql-classification.json" + + - id: route + name: Route language analysis fail closed + if: always() + env: + EVENT_NAME: ${{ github.event_name }} + CHECKOUT_OUTCOME: ${{ steps.checkout.outcome }} + CLASSIFIER_OUTCOME: ${{ steps.what.outcome }} + APP_ROUTE: ${{ steps.what.outputs.app }} + PYTHON_ROUTE: ${{ steps.what.outputs.python }} + ACTIONS_ROUTE: ${{ steps.what.outputs.actions }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == schedule || "$EVENT_NAME" == workflow_dispatch ]]; then + app=true + python=true + actions=true + elif [[ "$CHECKOUT_OUTCOME" == success && "$CLASSIFIER_OUTCOME" == success && + "$APP_ROUTE" =~ ^(true|false)$ && "$PYTHON_ROUTE" =~ ^(true|false)$ && + "$ACTIONS_ROUTE" =~ ^(true|false)$ ]]; then + app=$APP_ROUTE + python=$PYTHON_ROUTE + actions=$ACTIONS_ROUTE + else + echo "::warning::CodeQL classification failed; routing every language." + app=true + python=true + actions=true + fi + echo "app=$app" >> "$GITHUB_OUTPUT" + echo "python=$python" >> "$GITHUB_OUTPUT" + echo "actions=$actions" >> "$GITHUB_OUTPUT" + + analyze-swift: + name: analyze (swift) + needs: classify + if: always() && needs.classify.outputs.app == 'true' + runs-on: macos-15 timeout-minutes: 60 permissions: - security-events: write # the only reason this workflow needs more than read + security-events: write contents: read - strategy: - fail-fast: false - matrix: - include: - # Swift has to be built, and only a macOS runner can. See the header. - - language: swift - runner: macos-15 - build-mode: manual - # The site generator, the packaging scripts and the workflows themselves. Nothing to build. - - language: python - runner: ubuntu-24.04 - build-mode: none - - language: actions - runner: ubuntu-24.04 - build-mode: none steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: - # Code scanning reports against the PR head, so compile that exact commit rather than the merge ref. ref: ${{ github.event.pull_request.head.sha || github.sha }} - # Everything that is not the compilation happens before init, and deliberately so. Once init has run, - # CodeQL traces the job by preloading an x86_64 libtrace.dylib through DYLD_INSERT_LIBRARIES, which puts - # every child process under Rosetta 2 — and Homebrew refuses to install an x86_64 build into the ARM - # prefix: "Cannot install under Rosetta 2 in ARM default prefix". Generating the project needs no tracing - # anyway; only the compiler does. + # Everything that is not compilation happens before init. Once init runs, CodeQL tracing preloads an x86_64 + # library through DYLD_INSERT_LIBRARIES, putting child processes under Rosetta where ARM Homebrew refuses work. - name: Prepare the project - if: matrix.build-mode == 'manual' run: | set -euo pipefail - # Newest Xcode only. CI's test job covers the oldest-supported compiler; this build exists to give - # CodeQL something to trace, and tracing it twice would double the cost for the same findings. sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" xcodebuild -version - # Analyze the exact committed graph that CI reviewed and Xcode Cloud discovers. xcodebuild -project Flowlight.xcodeproj -list >/dev/null - uses: github/codeql-action/init@7999b86c43a865dc79d8923397f35af22de63401 # v4 with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # security-extended over the default suite: this app holds decrypted request bodies and a CA key, so - # the extra precision-medium queries are worth the false positives they bring. + languages: swift + build-mode: manual queries: security-extended - name: Build for analysis - if: matrix.build-mode == 'manual' run: | set -euo pipefail - # Signing off, as in the test job: analysis needs the compilation, not a runnable app. `clean build` - # rather than `build`, because CodeQL only sees the files the compiler actually recompiles and an - # incremental build would hand it a fraction of the sources. xcodebuild clean build \ -project Flowlight.xcodeproj \ -scheme Flowlight \ @@ -94,4 +127,73 @@ jobs: - uses: github/codeql-action/analyze@7999b86c43a865dc79d8923397f35af22de63401 # v4 with: - category: /language:${{ matrix.language }} + category: /language:swift + + analyze-python: + name: analyze (python) + needs: classify + if: always() && needs.classify.outputs.python == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + security-events: write + contents: read + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: github/codeql-action/init@7999b86c43a865dc79d8923397f35af22de63401 # v4 + with: + languages: python + build-mode: none + queries: security-extended + + - uses: github/codeql-action/analyze@7999b86c43a865dc79d8923397f35af22de63401 # v4 + with: + category: /language:python + + analyze-actions: + name: analyze (actions) + needs: classify + if: always() && needs.classify.outputs.actions == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + security-events: write + contents: read + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: github/codeql-action/init@7999b86c43a865dc79d8923397f35af22de63401 # v4 + with: + languages: actions + build-mode: none + queries: security-extended + + - uses: github/codeql-action/analyze@7999b86c43a865dc79d8923397f35af22de63401 # v4 + with: + category: /language:actions + + gate: + name: CodeQL / gate + if: always() + needs: [classify, analyze-swift, analyze-python, analyze-actions] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Require successful classification and every routed language + env: + CLASSIFY_RESULT: ${{ needs.classify.result }} + SWIFT_ROUTE: ${{ needs.classify.outputs.app }} + PYTHON_ROUTE: ${{ needs.classify.outputs.python }} + ACTIONS_ROUTE: ${{ needs.classify.outputs.actions }} + SWIFT_RESULT: ${{ needs.analyze-swift.result }} + PYTHON_RESULT: ${{ needs.analyze-python.result }} + ACTIONS_RESULT: ${{ needs.analyze-actions.result }} + run: scripts/check-codeql-gate.sh diff --git a/Flowlight.xcodeproj/project.pbxproj b/Flowlight.xcodeproj/project.pbxproj index 16006e3e..085efa62 100644 --- a/Flowlight.xcodeproj/project.pbxproj +++ b/Flowlight.xcodeproj/project.pbxproj @@ -2252,7 +2252,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 77; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -2267,7 +2267,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.17.2; + MARKETING_VERSION = 0.17.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2313,7 +2313,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 77; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 38RJUJHKZS; ENABLE_HARDENED_RUNTIME = YES; @@ -2328,7 +2328,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.17.2; + MARKETING_VERSION = 0.17.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2393,7 +2393,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 77; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -2414,7 +2414,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.17.2; + MARKETING_VERSION = 0.17.3; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/Flowlight/App/AppNavigation.swift b/Flowlight/App/AppNavigation.swift index 8f4f38d2..fc5e1617 100644 --- a/Flowlight/App/AppNavigation.swift +++ b/Flowlight/App/AppNavigation.swift @@ -1,7 +1,7 @@ import SwiftUI enum SidebarItem: String, CaseIterable, Identifiable { - case live, agents, reports, alerts, rules, ask, inspect, devices, coverage, capture, visualize + case live, agents, reports, alerts, rules, ask, inspect, sources, visualize var id: String { rawValue } static var availableCases: [SidebarItem] { @@ -27,9 +27,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .rules: return "rules" case .ask: return "ask" case .inspect: return "inspection" - case .devices: return "devices" - case .coverage: return "coverage" - case .capture: return "capture" + case .sources: return "sources" } } @@ -45,9 +43,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .rules: return "Rules" case .ask: return "Ask" case .inspect: return "Inspect" - case .devices: return "Devices" - case .coverage: return "Coverage" - case .capture: return "Capture" + case .sources: return "Sources" } } @@ -60,10 +56,8 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .alerts: return L("Alerts") case .rules: return L("Rules") case .ask: return L("Ask") - case .devices: return L("Devices") - case .coverage: return L("Coverage") case .inspect: return L("Inspect") - case .capture: return L("Capture") + case .sources: return L("Sources") } } var icon: String { @@ -75,10 +69,8 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .alerts: return "exclamationmark.triangle" case .rules: return "hand.raised" case .ask: return "text.bubble" - case .devices: return "dot.radiowaves.left.and.right" - case .coverage: return "circle.dashed.inset.filled" case .inspect: return "lock.open.display" - case .capture: return "antenna.radiowaves.left.and.right" + case .sources: return "antenna.radiowaves.left.and.right" } } /// The exact visual order of the sidebar. Enum declaration order is an implementation detail and used to @@ -101,7 +93,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .live, .agents, .reports, .visualize, .alerts: return .traffic case .ask, .inspect: return .investigate case .rules: return .control - case .devices, .coverage, .capture: return .sources + case .sources: return .sources } } @@ -114,7 +106,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { return true case .alerts: return scope.hosts.isEmpty - case .ask, .rules, .devices, .coverage, .capture: + case .ask, .rules, .sources: return false } } @@ -145,12 +137,37 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .traffic: order = [.live, .agents, .reports, .visualize, .alerts] case .investigate: order = [.ask, .inspect] case .control: order = [.rules] - case .sources: order = [.devices, .coverage, .capture] + case .sources: order = [.sources] } return order.filter { SidebarItem.availableCases.contains($0) } } } +/// The three operational views behind the single Sources destination. Keeping this in shared navigation lets a +/// status notice open the exact recovery surface without putting three configuration screens back in the sidebar. +enum SourcesTab: String, CaseIterable, Identifiable, Sendable { + case collector, devices, coverage + + var id: String { rawValue } + + @MainActor var title: String { + switch self { + case .collector: return L("Collector") + case .devices: return L("Devices") + case .coverage: return L("Coverage") + } + } +} + +/// Alert review and its detection tuning share a destination, but app-wide alert links must still be able to select +/// the review list explicitly instead of inheriting whichever tab happened to be visible. +enum AlertsTab: String, CaseIterable, Identifiable { + case alerts, detection + + var id: String { rawValue } + @MainActor var title: String { self == .alerts ? L("Alerts") : L("Detection") } +} + /// The three views inside Rules. This lives with the shared navigation state so an app-wide notice can take someone /// straight to a changed pack, while ordinary rule links always land back on the rule list. enum RulesTab: String, CaseIterable, Identifiable { @@ -189,9 +206,25 @@ struct ReportRequest: Equatable { @MainActor final class AppNavigation: ObservableObject { @Published private(set) var selection: SidebarItem = { - let restored = SidebarItem(rawValue: UserDefaults.standard.string(forKey: "FLScreen") ?? "") ?? .live + let raw = UserDefaults.standard.string(forKey: "FLScreen") ?? "" + // Preserve restored windows and screenshot arguments from before Devices, Coverage and Capture became tabs. + let restored = ["devices", "coverage", "capture"].contains(raw) + ? SidebarItem.sources + : SidebarItem(rawValue: raw) ?? .live return SidebarItem.availableCases.contains(restored) ? restored : .live }() + @Published var sourcesTab: SourcesTab = { + let defaults = UserDefaults.standard + if let explicit = SourcesTab(rawValue: defaults.string(forKey: "FLSourcesTab") ?? "") { + return explicit + } + switch defaults.string(forKey: "FLScreen") { + case "devices": return .devices + case "coverage": return .coverage + default: return .collector + } + }() + @Published var alertsTab: AlertsTab = .alerts @Published var rulesTab: RulesTab = { RulesTab(rawValue: UserDefaults.standard.string(forKey: "FLRulesTab") ?? "") ?? .rules }() @@ -213,6 +246,7 @@ final class AppNavigation: ObservableObject { func show(_ item: SidebarItem) { guard SidebarItem.availableCases.contains(item) else { return } trail.removeAll() + if item == .alerts { alertsTab = .alerts } if item == .rules { rulesTab = .rules } selection = item } @@ -245,10 +279,19 @@ final class AppNavigation: ObservableObject { showRules() } + func showAlerts() { + show(.alerts) + } + func showRules() { show(.rules) } + func showSources(_ tab: SourcesTab = .collector) { + sourcesTab = tab + show(.sources) + } + func showPackReviews() { show(.rules) rulesTab = .packs diff --git a/Flowlight/App/FlowlightApp.swift b/Flowlight/App/FlowlightApp.swift index 98d66b4b..dee23d8a 100644 --- a/Flowlight/App/FlowlightApp.swift +++ b/Flowlight/App/FlowlightApp.swift @@ -15,6 +15,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { runStartupIfReady() } + func applicationWillFinishLaunching(_ notification: Notification) { + // Screenshot runs need a deterministic appearance even when the reviewing Mac itself uses Dark Mode. + // Keep the override demo-only so no launch argument can change the appearance of a real user's app. + guard DemoData.isEnabled, + UserDefaults.standard.bool(forKey: "FLForceLightAppearance") else { return } + NSApp.appearance = NSAppearance(named: .aqua) + } + func applicationDidFinishLaunching(_ notification: Notification) { Self.didFinishLaunching = true Self.runStartupIfReady() diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift index e8c5c4e0..2bd069b3 100644 --- a/Flowlight/App/TrafficMonitor.swift +++ b/Flowlight/App/TrafficMonitor.swift @@ -41,8 +41,8 @@ final class TrafficMonitor: ObservableObject { @Published private(set) var unacknowledgedAlerts = 0 @Published private(set) var captureState: PacketSniffer.State = .stopped @Published private(set) var hostnamesLearned = 0 - /// Share of traffic in the last hour with a hostname / with at least a network owner. - @Published private(set) var coverage: (named: Double, owned: Double) = (0, 0) + /// Share of traffic in the last hour with a hostname / with at least a network owner, plus whether the window has evidence. + @Published private(set) var coverage: (named: Double, owned: Double, hasTraffic: Bool) = (0, 0, false) let sniffer = PacketSniffer() /// First-launch offer to enable packet capture. @Published var showCaptureOnboarding = false diff --git a/Flowlight/Ask/AskController.swift b/Flowlight/Ask/AskController.swift index 4fc7f0f5..60864ee8 100644 --- a/Flowlight/Ask/AskController.swift +++ b/Flowlight/Ask/AskController.swift @@ -150,7 +150,8 @@ final class AskController: ObservableObject { do { let result = try await Self.run(call, db: db, environment: snapshot) await recorder.finish(call, summary: result.summary, filter: result.filter, from: result.from, - to: result.to, chart: result.chart, screen: result.screen) + to: result.to, chart: result.chart, screen: result.screen, + sourcesTab: result.sourcesTab) return result.json } catch { let message = (error as? AskQueryRunner.Failure)?.description ?? error.localizedDescription @@ -216,7 +217,8 @@ private actor CallRecorder { } func finish(_ call: AskCall, summary: String, failed: Bool = false, filter: TrafficFilter? = nil, - from: Date? = nil, to: Date? = nil, chart: AskChart? = nil, screen: SidebarItem? = nil) { + from: Date? = nil, to: Date? = nil, chart: AskChart? = nil, screen: SidebarItem? = nil, + sourcesTab: SourcesTab? = nil) { guard let index = calls.lastIndex(where: { $0.call.id == call.id }) else { return } calls[index].summary = summary calls[index].failed = failed @@ -225,6 +227,7 @@ private actor CallRecorder { calls[index].to = to calls[index].chart = chart.flatMap { $0.isEmpty ? nil : $0 } calls[index].screen = screen + calls[index].sourcesTab = sourcesTab } func sent(_ body: String) { bodies.append(body) } diff --git a/Flowlight/Ask/AskProvider.swift b/Flowlight/Ask/AskProvider.swift index 0cae1b45..bb1c97d5 100644 --- a/Flowlight/Ask/AskProvider.swift +++ b/Flowlight/Ask/AskProvider.swift @@ -103,6 +103,8 @@ struct AskRecordedCall: Identifiable, Equatable, Sendable { var chart: AskChart? /// Which app screen clicking should open when Reports cannot represent this result. var screen: SidebarItem? + /// The exact operational tab when that destination is Sources. + var sourcesTab: SourcesTab? = nil } /// What a provider needs to answer. The conversation is only ever the question and the results of queries diff --git a/Flowlight/Ask/AskQueryRunner.swift b/Flowlight/Ask/AskQueryRunner.swift index 97215fb2..d7b0a804 100644 --- a/Flowlight/Ask/AskQueryRunner.swift +++ b/Flowlight/Ask/AskQueryRunner.swift @@ -22,6 +22,8 @@ enum AskQueryRunner { var chart: AskChart? /// Where the answer points for a question about the app rather than about traffic. var screen: SidebarItem? + /// The exact operational tab when that screen is Sources. + var sourcesTab: SourcesTab? = nil } enum Failure: Error, CustomStringConvertible { @@ -114,7 +116,7 @@ enum AskQueryRunner { summary: environment.inspecting ? L("%@, inspection on, %lld rules", environment.captureSource, environment.rules.count) : L("%@, inspection off, %lld rules", environment.captureSource, environment.rules.count), - screen: .capture) + screen: .sources, sourcesTab: .collector) } private static func howTo(_ topic: String) -> Result { @@ -125,7 +127,8 @@ enum AskQueryRunner { } return Result(json: encode(matches.map(\.asDictionary)), summary: matches.map(\.title).joined(separator: ", "), - screen: matches.first?.screen) + screen: matches.first?.screen, + sourcesTab: matches.first?.sourcesTab) } /// `what` names the JSON field the model reads, so it stays English; the summary is what the panel shows and @@ -308,7 +311,7 @@ enum AskQueryRunner { return Result(json: encode(payload), summary: rows.isEmpty ? L("no device changes") : rows.prefix(3).map { L("%@ · %lld changes", $0.name, $0.total) }.joined(separator: ", "), - from: window.from, to: window.to, screen: .devices) + from: window.from, to: window.to, screen: .sources, sourcesTab: .devices) } private static func overTime(db: TrafficDatabase, window: (from: Date, to: Date), app: String?, diff --git a/Flowlight/Ask/FeatureGuide.swift b/Flowlight/Ask/FeatureGuide.swift index 07fd12da..c0d7a7ac 100644 --- a/Flowlight/Ask/FeatureGuide.swift +++ b/Flowlight/Ask/FeatureGuide.swift @@ -19,6 +19,8 @@ struct FeatureGuide: Equatable, Sendable, Identifiable { var steps: [String] /// Where it lives, so an answer can offer to go there. var screen: SidebarItem? + /// The operational tab behind Sources, when the destination needs more precision than one sidebar item. + var sourcesTab: SourcesTab? = nil /// What it can't do. Left out of an answer, these are the things people discover the hard way. var caveats: [String] = [] /// Extra words someone might use for it. @@ -45,9 +47,10 @@ extension FeatureGuide { FeatureGuide( id: "capture", title: "Capture source", summary: "Where traffic comes from. The Network Extension is a content filter that sits in the data path and attributes every flow to its app; the nettop sampler needs no entitlement but samples once a second and misses flows shorter than that.", - steps: ["Open Capture.", "Under Capture source, pick Network Extension or nettop sampler.", + steps: ["Open Sources and choose Collector.", "Pick Network Extension or nettop sampler.", "For the extension, press Install & enable filter and approve it in System Settings."], - screen: .capture, + screen: .sources, + sourcesTab: .collector, caveats: ["macOS runs one content filter at a time. If a VPN or a security agent holds that slot, Flowlight's filter installs and connects but is never handed a flow.", "Only the extension can refuse a connection. The sampler counts traffic after the fact."], keywords: ["nettop", "network extension", "content filter", "source", "no traffic", "nothing showing"]), @@ -55,8 +58,9 @@ extension FeatureGuide { FeatureGuide( id: "hostnames", title: "Hostnames", summary: "Turning IP addresses into names. The extension reads TLS SNI and DNS answers itself. On the sampler, a one-time packet-capture setup is what lets Flowlight see those names.", - steps: ["Open Capture.", "Turn on Read hostnames from the network.", "Give the helper permission when macOS asks."], - screen: .capture, + steps: ["Open Sources and choose Collector.", "Turn on Read hostnames from the network.", "Give the helper permission when macOS asks."], + screen: .sources, + sourcesTab: .collector, caveats: ["Without it the sampler falls back to reverse DNS, which is often missing or wrong."], keywords: ["dns", "sni", "domain", "ip only", "no domain names", "bpf", "packet capture"]), @@ -136,7 +140,7 @@ extension FeatureGuide { FeatureGuide( id: "alerts", title: "Alerts", summary: "Explainable, per-app alerts: traffic spikes, unusual numbers of destinations, first contact with a domain, sensitive channels, possible exfiltration, agents active while you are away, and refused connections.", - steps: ["Open Alerts to review them.", "Adjust the thresholds in Settings ⌘, under Detection."], + steps: ["Open Alerts to review them.", "Choose Detection there to adjust the thresholds."], screen: .alerts, caveats: ["Every app gets a learning period before first-contact and spike rules fire."], keywords: ["alert", "anomaly", "warning", "threshold", "notification", "exfiltration"]), @@ -172,8 +176,9 @@ extension FeatureGuide { FeatureGuide( id: "devices", title: "Devices: camera, Bluetooth and USB", summary: "Whether each camera is active, which Bluetooth and USB devices are paired or attached, and when that changed. Every channel is off until you turn it on.", - steps: ["Open Devices.", "Turn on Watch camera activity, Watch Bluetooth, or Watch USB and external storage."], - screen: .devices, + steps: ["Open Sources and choose Devices.", "Turn on Watch camera activity, Watch Bluetooth, or Watch USB and external storage."], + screen: .sources, + sourcesTab: .devices, caveats: ["Camera activity names the camera, not the app using it; Flowlight never reads frames or requests camera access.", "No Bluetooth or USB byte counts: macOS keeps no per-app accounting for those channels.", "The Bluetooth app list is who asked for access, not who was granted it."], @@ -200,9 +205,9 @@ extension FeatureGuide { FeatureGuide( id: "data", title: "Your data and retention", summary: "Everything is a SQLite database in Application Support. Seconds are kept briefly, minutes and hours longer, daily totals indefinitely, alerts for 90 days.", - steps: ["Open Capture and use Clear all data to delete everything.", + steps: ["Open Settings with ⌘, and choose Data & Privacy.", "Use Clear All Data to delete everything.", "Or remove ~/Library/Application Support/Flowlight."], - screen: .capture, + screen: nil, keywords: ["delete", "database", "retention", "privacy", "clear", "storage", "disk"]), FeatureGuide( @@ -218,7 +223,7 @@ extension FeatureGuide { guard guide.id == "capture" else { return guide } var storeGuide = guide storeGuide.summary = "Where traffic comes from. The Mac App Store build uses Flowlight's content-filter extension to attribute every flow to its app." - storeGuide.steps = ["Open Capture.", "Under Content filter, press Enable Filter and approve it in System Settings."] + storeGuide.steps = ["Open Sources and choose Collector.", "Under Content filter, press Enable Filter and approve it in System Settings."] storeGuide.caveats = ["macOS runs one content filter at a time. If a VPN or a security agent holds that slot, Flowlight cannot receive traffic."] storeGuide.keywords = ["network extension", "content filter", "source", "no traffic", "nothing showing"] return storeGuide diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings index 3d41e10d..b5a39be4 100644 --- a/Flowlight/Localization/de.lproj/Localizable.strings +++ b/Flowlight/Localization/de.lproj/Localizable.strings @@ -71,7 +71,6 @@ "Devices" = "Geräte"; "Coverage" = "Abdeckung"; "Inspect" = "Inspizieren"; -"Capture" = "Erfassung"; "Traffic" = "Datenverkehr"; "Investigate" = "Untersuchen"; "Control" = "Steuerung"; @@ -87,6 +86,7 @@ "Questions & Answers" = "Fragen & Antworten"; "What Flowlight Reads on This Mac" = "Was Flowlight auf diesem Mac liest"; "Privacy" = "Datenschutz"; +"Data & Privacy" = "Daten & Datenschutz"; "Report an Issue" = "Problem melden"; "Turn Focus Off" = "Fokus aus"; "Turn Focus On" = "Fokus ein"; @@ -634,8 +634,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "Allen aufgezeichneten Datenverkehr, alle Basislinien und Warnungen löschen?"; "Delete everything" = "Alles löschen"; -"nettop sampler · the extension didn't answer" = "nettop-Sampler · die Erweiterung hat nicht geantwortet"; -"Network Extension · unavailable" = "Netzwerkerweiterung · nicht verfügbar"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Die Filtererweiterung ist nicht erreichbar. Aktiviere sie unter Inhaltsfilter und klicke dann auf Aktualisieren."; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — aktiviere Flowlight unter Inhaltsfilter und klicke dann auf Aktualisieren."; "%@ — the content filter is unavailable" = "%@ — der Inhaltsfilter ist nicht verfügbar"; @@ -644,8 +642,6 @@ "Reconnect to the content filter" = "Erneut mit dem Inhaltsfilter verbinden"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Wenn ein VPN oder Sicherheitsagent diesen Platz bereits belegt – Palo Alto Networks GlobalProtect, CrowdStrike Falcon und ähnliche verwenden ihn –, kann der Flowlight-Filter keinen Datenverkehr empfangen, bis dieser Filter deaktiviert wird."; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "Der Filter antwortet nicht, daher empfängt Flowlight keinen Datenverkehr. Aktiviere ihn unter Inhaltsfilter und klicke dann auf Aktualisieren."; -"%@ · receiving" = "%@ · empfängt"; -"%@ · nothing arriving yet" = "%@ · noch kommt nichts an"; "Source" = "Quelle"; "Content filter" = "Inhaltsfilter"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "Beobachtet geeignete TCP- und UDP-Flows beim Öffnen. Erfordert die Berechtigung für die Netzwerkerweiterung und Ihre Freigabe."; @@ -702,7 +698,7 @@ "Without this, most traffic shows only an IP address or network owner." = "Ohne dies zeigt der meiste Datenverkehr nur eine IP-Adresse oder einen Netzbetreiber."; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "Flowlight liest DNS-Antworten und TLS-Servernamen auf Ihrer primären Netzwerkschnittstelle, um Verbindungen zu benennen."; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "Paketinhalte werden nie gespeichert oder irgendwohin gesendet. Nur Hostnamen werden behalten."; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "macOS erlaubt nur Administratoren das Lesen von Paketen, daher wird einmal nach Ihrem Passwort gefragt. Sie können dies jederzeit unter „Erfassung“ entfernen."; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "macOS erlaubt nur Administratoren das Lesen von Paketen, daher wird einmal nach Ihrem Passwort gefragt. Sie können dies jederzeit unter „Quellen › Collector“ entfernen."; "Identify network owners when no hostname is available" = "Netzbetreiber ermitteln, wenn kein Hostname verfügbar ist"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "Standardmäßig aktiv. Sendet öffentliche Ziel-IPs an den DNS-Dienst von Team Cymru. Ausschalten, damit diese Abfragen auf dem Mac bleiben."; "Not Now" = "Jetzt nicht"; @@ -733,8 +729,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "Noch nichts aufgezeichnet"; +"Nothing recorded in the last hour" = "In der letzten Stunde wurde nichts aufgezeichnet"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "Die Abdeckung wird aus dem Datenverkehr berechnet, den Flowlight schon aufgezeichnet hat. Lassen Sie es einen Moment laufen."; -"%@ of traffic fully accounted for" = "%@ des Datenverkehrs vollständig erfasst"; "How much of this Mac is covered" = "Wie viel von diesem Mac abgedeckt ist"; "HTTPS inspection is on" = "Die HTTPS-Inspektion ist ein"; "HTTPS inspection is off" = "Die HTTPS-Inspektion ist aus"; @@ -782,8 +778,6 @@ "Camera" = "Kamera"; "Started" = "Gestartet"; "Unknown camera" = "Unbekannte Kamera"; -"%@ · nothing active" = "%@ · nichts aktiv"; -"%@ · %lld active" = "%@ · %lld aktiv"; "Watch camera activity" = "Kameraaktivität überwachen"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "Bluetooth, USB und Kameraaktivität sind ausgeschaltet, bis Sie sie einschalten. Dafür ist keine neue Berechtigung nötig – sie sind ausgeschaltet, weil ihre Überwachung erweitert, worauf Flowlight schaut, und das sollte Ihre Entscheidung sein, nicht die eines Updates.\n\nBluetooth und USB zeigen, was verbunden ist. Die Kameraaktivität zeigt, ob eine Kamera aktiv ist, nicht welche App sie verwendet. Flowlight öffnet die Kamera nie und liest keine Bilder."; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "Keine Nutzdaten und keine erfundene Zuordnung. Bluetooth und USB zeigen Verbindungen, während die Kameraüberwachung nur den Aktivstatus jedes Geräts liest. macOS stellt hier keine öffentliche Identität der App bereit, die die Kamera verwendet. Deshalb nennt Flowlight die Kamera, rät nie bei der App und liest keine Bilder."; @@ -1035,7 +1029,6 @@ "The network is quiet" = "Das Netzwerk ist ruhig"; "No app has sent or received data in the last few seconds." = "In den letzten Sekunden hat keine App Daten gesendet oder empfangen."; "Waiting for traffic data" = "Warten auf Verkehrsdaten"; -"Open Capture Settings" = "Erfassungseinstellungen öffnen"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "Einen gewählten Endpunkt selbst beantworten, statt die Anfrage zum Server zu lassen – ein 500, ein Rate-Limit, ein fehlerhafter Body oder eine lange Wartezeit – und beobachten, wie der Agent damit umgeht."; @@ -1257,8 +1250,8 @@ "Until I resume" = "Bis ich fortsetze"; "Pause Blocking" = "Blockieren pausieren"; "Stand every rule down for a while, without deleting anything" = "Alle Regeln vorübergehend außer Kraft setzen, ohne etwas zu löschen"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "Eine Regel wird beobachtet, aber nicht durchgesetzt. Nur die Netzwerkerweiterung sitzt im Datenpfad und kann eine Verbindung verweigern; der nettop-Sampler zählt Datenverkehr im Nachhinein. Wählen Sie unter „Erfassung“ die Netzwerkerweiterung, um diese Regeln durchzusetzen."; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "%lld Regeln werden beobachtet, aber nicht durchgesetzt. Nur die Netzwerkerweiterung sitzt im Datenpfad und kann eine Verbindung verweigern; der nettop-Sampler zählt Datenverkehr im Nachhinein. Wählen Sie unter „Erfassung“ die Netzwerkerweiterung, um diese Regeln durchzusetzen."; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "Eine Regel wird beobachtet, aber nicht durchgesetzt. Nur die Netzwerkerweiterung sitzt im Datenpfad und kann eine Verbindung verweigern; der nettop-Sampler zählt Datenverkehr im Nachhinein. Wählen Sie unter „Quellen › Collector“ die Netzwerkerweiterung, um diese Regeln durchzusetzen."; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld Regeln werden beobachtet, aber nicht durchgesetzt. Nur die Netzwerkerweiterung sitzt im Datenpfad und kann eine Verbindung verweigern; der nettop-Sampler zählt Datenverkehr im Nachhinein. Wählen Sie unter „Quellen › Collector“ die Netzwerkerweiterung, um diese Regeln durchzusetzen."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Eine Regel wird beobachtet, aber nicht durchgesetzt – eine Regel, die einen Pfad benennt, lässt sich nur mit HTTPS-Inspektion abgleichen, und die ist aus."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld Regeln werden beobachtet, aber nicht durchgesetzt – eine Regel, die einen Pfad benennt, lässt sich nur mit HTTPS-Inspektion abgleichen, und die ist aus."; "In force" = "In Kraft"; @@ -1308,7 +1301,7 @@ "Apply a Template" = "Vorlage anwenden"; "%@ · in force at once" = "%@ · sofort in Kraft"; "%@ · shown first" = "%@ · wird erst angezeigt"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "Jede schreibt gewöhnliche Regeln in die Liste, wo sie wie alles andere gelesen, ausgeschaltet oder gelöscht werden können. Verbindungen zu verweigern ist Aufgabe der Netzwerkerweiterung; ist in der Erfassung der nettop-Sampler gewählt, werden diese Regeln nur beobachtet statt durchgesetzt."; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "Jede schreibt gewöhnliche Regeln in die Liste, wo sie wie alles andere gelesen, ausgeschaltet oder gelöscht werden können. Verbindungen zu verweigern ist Aufgabe der Netzwerkerweiterung; ist unter Quellen › Collector der nettop-Sampler gewählt, werden diese Regeln nur beobachtet statt durchgesetzt."; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "Optional. Leer gelassen gilt dies für alles auf dem Mac; eine genannte App schränkt es auf diese App und die von ihr gestarteten Tools ein."; "A domain, an IP address, or a range" = "Eine Domain, eine IP-Adresse oder ein Bereich"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "Diese benennen eine HTTP-Methode, daher kann nur die HTTPS-Inspektion sie durchsetzen. Ist sie aus, stehen sie beobachtend in der Liste."; diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings index 84397c40..89b0a0f2 100644 --- a/Flowlight/Localization/en.lproj/Localizable.strings +++ b/Flowlight/Localization/en.lproj/Localizable.strings @@ -73,7 +73,6 @@ "Devices" = "Devices"; "Coverage" = "Coverage"; "Inspect" = "Inspect"; -"Capture" = "Capture"; "Traffic" = "Traffic"; "Investigate" = "Investigate"; "Control" = "Control"; @@ -89,6 +88,7 @@ "Questions & Answers" = "Questions & Answers"; "What Flowlight Reads on This Mac" = "What Flowlight Reads on This Mac"; "Privacy" = "Privacy"; +"Data & Privacy" = "Data & Privacy"; "Report an Issue" = "Report an Issue"; "Turn Focus Off" = "Turn Focus Off"; "Turn Focus On" = "Turn Focus On"; @@ -606,8 +606,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "Delete all recorded traffic, baselines and alerts?"; "Delete everything" = "Delete everything"; -"nettop sampler · the extension didn't answer" = "nettop sampler · the extension didn't answer"; -"Network Extension · unavailable" = "Network Extension · unavailable"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh."; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — enable Flowlight under Content filter, then press Refresh."; "%@ — the content filter is unavailable" = "%@ — the content filter is unavailable"; @@ -616,8 +614,6 @@ "Reconnect to the content filter" = "Reconnect to the content filter"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled."; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh."; -"%@ · receiving" = "%@ · receiving"; -"%@ · nothing arriving yet" = "%@ · nothing arriving yet"; "Source" = "Source"; "Content filter" = "Content filter"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval."; @@ -674,7 +670,7 @@ "Without this, most traffic shows only an IP address or network owner." = "Without this, most traffic shows only an IP address or network owner."; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections."; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "Packet contents are never stored or sent anywhere. Only hostnames are kept."; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture."; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector."; "Identify network owners when no hostname is available" = "Identify network owners when no hostname is available"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac."; "Not Now" = "Not Now"; @@ -705,8 +701,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "Nothing recorded yet"; +"Nothing recorded in the last hour" = "Nothing recorded in the last hour"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment."; -"%@ of traffic fully accounted for" = "%@ of traffic fully accounted for"; "How much of this Mac is covered" = "How much of this Mac is covered"; "HTTPS inspection is on" = "HTTPS inspection is on"; "HTTPS inspection is off" = "HTTPS inspection is off"; @@ -754,8 +750,6 @@ "Camera" = "Camera"; "Started" = "Started"; "Unknown camera" = "Unknown camera"; -"%@ · nothing active" = "%@ · nothing active"; -"%@ · %lld active" = "%@ · %lld active"; "Watch camera activity" = "Watch camera activity"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames."; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames."; @@ -984,7 +978,6 @@ "The network is quiet" = "The network is quiet"; "No app has sent or received data in the last few seconds." = "No app has sent or received data in the last few seconds."; "Waiting for traffic data" = "Waiting for traffic data"; -"Open Capture Settings" = "Open Capture Settings"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it."; @@ -1189,8 +1182,8 @@ "Until I resume" = "Until I resume"; "Pause Blocking" = "Pause Blocking"; "Stand every rule down for a while, without deleting anything" = "Stand every rule down for a while, without deleting anything"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules."; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules."; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules."; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off."; "In force" = "In force"; @@ -1240,7 +1233,7 @@ "Apply a Template" = "Apply a Template"; "%@ · in force at once" = "%@ · in force at once"; "%@ · shown first" = "%@ · shown first"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out."; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out."; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started."; "A domain, an IP address, or a range" = "A domain, an IP address, or a range"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching."; diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings index 97a8f55a..3cda2c08 100644 --- a/Flowlight/Localization/es.lproj/Localizable.strings +++ b/Flowlight/Localization/es.lproj/Localizable.strings @@ -71,7 +71,6 @@ "Devices" = "Dispositivos"; "Coverage" = "Cobertura"; "Inspect" = "Inspeccionar"; -"Capture" = "Captura"; "Traffic" = "Tráfico"; "Investigate" = "Investigar"; "Control" = "Control"; @@ -87,6 +86,7 @@ "Questions & Answers" = "Preguntas y respuestas"; "What Flowlight Reads on This Mac" = "Qué lee Flowlight en este Mac"; "Privacy" = "Privacidad"; +"Data & Privacy" = "Datos y privacidad"; "Report an Issue" = "Informar de un problema"; "Turn Focus Off" = "Desactivar el foco"; "Turn Focus On" = "Activar el foco"; @@ -634,8 +634,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "¿Eliminar todo el tráfico registrado, las líneas base y las alertas?"; "Delete everything" = "Eliminar todo"; -"nettop sampler · the extension didn't answer" = "Muestreador nettop · la extensión no ha respondido"; -"Network Extension · unavailable" = "Extensión de red · no disponible"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "No se pudo acceder a la extensión de filtro. Actívala en Filtro de contenido y pulsa Actualizar."; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — activa Flowlight en Filtro de contenido y pulsa Actualizar."; "%@ — the content filter is unavailable" = "%@ — el filtro de contenido no está disponible"; @@ -644,8 +642,6 @@ "Reconnect to the content filter" = "Volver a conectar con el filtro de contenido"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Si una VPN o un agente de seguridad ya ocupa ese espacio —Palo Alto Networks GlobalProtect, CrowdStrike Falcon y otros similares lo utilizan—, el filtro de Flowlight no podrá recibir tráfico hasta que se desactive dicho filtro."; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "El filtro no responde, por lo que Flowlight no recibe tráfico. Actívalo en Filtro de contenido y pulsa Actualizar."; -"%@ · receiving" = "%@ · recibiendo"; -"%@ · nothing arriving yet" = "%@ · todavía no llega nada"; "Source" = "Fuente"; "Content filter" = "Filtro de contenido"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "Observa los flujos TCP y UDP admitidos en el momento en que se abren. Requiere el permiso de la extensión de red y tu autorización."; @@ -702,7 +698,7 @@ "Without this, most traffic shows only an IP address or network owner." = "Sin esto, la mayor parte del tráfico solo muestra una dirección IP o el propietario de la red."; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "Flowlight lee las respuestas DNS y los nombres de servidor TLS de tu interfaz de red principal para dar nombre a las conexiones."; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "El contenido de los paquetes nunca se guarda ni se envía a ningún sitio. Solo se conservan los nombres de host."; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "macOS solo permite leer los paquetes a los administradores, así que esto pide tu contraseña una vez. Puedes quitarlo en cualquier momento en Captura."; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "macOS solo permite leer los paquetes a los administradores, así que esto pide tu contraseña una vez. Puedes quitarlo en cualquier momento en Fuentes › Colector."; "Identify network owners when no hostname is available" = "Identificar el propietario de la red cuando no hay nombre de host"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "Activado por omisión. Envía las IP públicas de destino al servicio DNS de Team Cymru. Si se desactiva, estas consultas no salen de tu Mac."; "Not Now" = "Ahora no"; @@ -733,8 +729,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "Aún no se ha registrado nada"; +"Nothing recorded in the last hour" = "No se ha registrado nada en la última hora"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "La cobertura se calcula a partir del tráfico que Flowlight ya ha registrado. Déjalo funcionando un momento."; -"%@ of traffic fully accounted for" = "%@ del tráfico contabilizado por completo"; "How much of this Mac is covered" = "Cuánto de este Mac está cubierto"; "HTTPS inspection is on" = "La inspección HTTPS está activada"; "HTTPS inspection is off" = "La inspección HTTPS está desactivada"; @@ -782,8 +778,6 @@ "Camera" = "Cámara"; "Started" = "Iniciada"; "Unknown camera" = "Cámara desconocida"; -"%@ · nothing active" = "%@ · nada activo"; -"%@ · %lld active" = "%@ · %lld activos"; "Watch camera activity" = "Vigilar la actividad de la cámara"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "El Bluetooth, el USB y la actividad de la cámara están desactivados hasta que los actives. No necesitan ningún permiso nuevo: están desactivados porque vigilarlos amplía lo que Flowlight observa, y esa decisión debe ser tuya, no de una actualización.\n\nEl Bluetooth y el USB muestran qué está conectado. La actividad de la cámara indica si cada cámara está activa, no qué app la está usando. Flowlight nunca abre la cámara ni lee fotogramas."; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "Sin cargas útiles ni atribuciones inventadas. El Bluetooth y el USB muestran las conexiones, mientras que la vigilancia de la cámara solo lee el estado activo de cada dispositivo. macOS no revela aquí públicamente qué app utiliza la cámara, por lo que Flowlight identifica la cámara, nunca adivina la app y nunca lee fotogramas."; @@ -1035,7 +1029,6 @@ "The network is quiet" = "La red está tranquila"; "No app has sent or received data in the last few seconds." = "Ninguna app ha enviado ni recibido datos en los últimos segundos."; "Waiting for traffic data" = "Esperando datos de tráfico"; -"Open Capture Settings" = "Abrir los ajustes de captura"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "Responder a un endpoint elegido desde Flowlight en lugar de dejar que la petición llegue al servidor —un 500, un límite de peticiones, un cuerpo mal formado o una espera larga— y ver qué hace el agente al respecto."; @@ -1257,8 +1250,8 @@ "Until I resume" = "Hasta que lo reanude"; "Pause Blocking" = "Pausar el bloqueo"; "Stand every rule down for a while, without deleting anything" = "Suspender todas las reglas un rato, sin eliminar nada"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "Una regla se está vigilando, pero no se aplica. Solo la extensión de red está en el camino de los datos y puede rechazar una conexión; el muestreador nettop cuenta el tráfico a posteriori. Selecciona la extensión de red en Captura para aplicar estas reglas."; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "%lld reglas se están vigilando, pero no se aplican. Solo la extensión de red está en el camino de los datos y puede rechazar una conexión; el muestreador nettop cuenta el tráfico a posteriori. Selecciona la extensión de red en Captura para aplicar estas reglas."; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "Una regla se está vigilando, pero no se aplica. Solo la extensión de red está en el camino de los datos y puede rechazar una conexión; el muestreador nettop cuenta el tráfico a posteriori. Selecciona la extensión de red en Fuentes › Colector para aplicar estas reglas."; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld reglas se están vigilando, pero no se aplican. Solo la extensión de red está en el camino de los datos y puede rechazar una conexión; el muestreador nettop cuenta el tráfico a posteriori. Selecciona la extensión de red en Fuentes › Colector para aplicar estas reglas."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Una regla se está vigilando, pero no se aplica: una regla que nombra una ruta solo puede compararse con la inspección HTTPS, y está desactivada."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld reglas se están vigilando, pero no se aplican: una regla que nombra una ruta solo puede compararse con la inspección HTTPS, y está desactivada."; "In force" = "En vigor"; @@ -1308,7 +1301,7 @@ "Apply a Template" = "Aplicar una plantilla"; "%@ · in force at once" = "%@ · en vigor de inmediato"; "%@ · shown first" = "%@ · se muestran primero"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "Cada una escribe reglas normales en la lista, donde pueden leerse, desactivarse o eliminarse como cualquier otra. Rechazar una conexión es tarea de la extensión de red, así que con el muestreador nettop seleccionado en Captura estas reglas se vigilan en lugar de aplicarse."; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "Cada una escribe reglas normales en la lista, donde pueden leerse, desactivarse o eliminarse como cualquier otra. Rechazar una conexión es tarea de la extensión de red, así que con el muestreador nettop seleccionado en Fuentes › Colector estas reglas se vigilan en lugar de aplicarse."; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "Opcional. Si se deja vacío, se aplica a todo el Mac; nombrar una app lo limita a esa app y a las herramientas que haya iniciado."; "A domain, an IP address, or a range" = "Un dominio, una dirección IP o un rango"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "Estas nombran un método HTTP, así que solo la inspección HTTPS puede aplicarlas. Con ella desactivada, se quedan en la lista vigilando."; diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings index 8c1b399a..77d49d96 100644 --- a/Flowlight/Localization/fr.lproj/Localizable.strings +++ b/Flowlight/Localization/fr.lproj/Localizable.strings @@ -71,7 +71,6 @@ "Devices" = "Appareils"; "Coverage" = "Couverture"; "Inspect" = "Inspecter"; -"Capture" = "Capture"; "Traffic" = "Trafic"; "Investigate" = "Analyser"; "Control" = "Contrôle"; @@ -87,6 +86,7 @@ "Questions & Answers" = "Questions et réponses"; "What Flowlight Reads on This Mac" = "Ce que Flowlight lit sur ce Mac"; "Privacy" = "Confidentialité"; +"Data & Privacy" = "Données et confidentialité"; "Report an Issue" = "Signaler un problème"; "Turn Focus Off" = "Désactiver Focus"; "Turn Focus On" = "Activer Focus"; @@ -634,8 +634,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "Supprimer tout le trafic enregistré, les références et les alertes ?"; "Delete everything" = "Tout supprimer"; -"nettop sampler · the extension didn't answer" = "Échantillonneur nettop · l'extension n'a pas répondu"; -"Network Extension · unavailable" = "Extension réseau · indisponible"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Impossible de joindre l’extension de filtrage. Activez-la dans Filtre de contenu, puis cliquez sur Actualiser."; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — activez Flowlight dans Filtre de contenu, puis cliquez sur Actualiser."; "%@ — the content filter is unavailable" = "%@ — le filtre de contenu est indisponible"; @@ -644,8 +642,6 @@ "Reconnect to the content filter" = "Se reconnecter au filtre de contenu"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Si un VPN ou un agent de sécurité occupe déjà cet emplacement — Palo Alto Networks GlobalProtect, CrowdStrike Falcon et autres l’utilisent — le filtre Flowlight ne peut recevoir aucun trafic tant que cet autre filtre n’est pas désactivé."; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "Le filtre ne répond pas, Flowlight ne reçoit donc aucun trafic. Activez-le dans Filtre de contenu, puis cliquez sur Actualiser."; -"%@ · receiving" = "%@ · réception en cours"; -"%@ · nothing arriving yet" = "%@ · rien ne parvient encore"; "Source" = "Source"; "Content filter" = "Filtre de contenu"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "Observe les flux TCP et UDP éligibles à leur ouverture. Nécessite le droit d'accès Extension réseau et votre autorisation."; @@ -702,7 +698,7 @@ "Without this, most traffic shows only an IP address or network owner." = "Sans cela, la plupart du trafic n'affiche qu'une adresse IP ou un propriétaire de réseau."; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "Flowlight lit les réponses DNS et les noms de serveur TLS sur votre interface réseau principale pour nommer les connexions."; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "Le contenu des paquets n'est jamais conservé ni envoyé où que ce soit. Seuls les noms d'hôte sont conservés."; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "macOS ne permet qu'aux administrateurs de lire les paquets : votre mot de passe est donc demandé une fois. Vous pouvez le retirer à tout moment dans Capture."; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "macOS ne permet qu'aux administrateurs de lire les paquets : votre mot de passe est donc demandé une fois. Vous pouvez le retirer à tout moment dans Sources › Collecteur."; "Identify network owners when no hostname is available" = "Identifier les propriétaires de réseau quand aucun nom d'hôte n'est disponible"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "Activé par défaut. Envoie les IP de destination publiques au service DNS de Team Cymru. À désactiver pour que ces recherches restent sur ce Mac."; "Not Now" = "Pas maintenant"; @@ -733,8 +729,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "Rien d'enregistré pour l'instant"; +"Nothing recorded in the last hour" = "Rien n'a été enregistré durant la dernière heure"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "La couverture est calculée à partir du trafic que Flowlight a déjà enregistré. Le laisser tourner un moment."; -"%@ of traffic fully accounted for" = "%@ du trafic entièrement pris en compte"; "How much of this Mac is covered" = "Quelle part de ce Mac est couverte"; "HTTPS inspection is on" = "L'inspection HTTPS est activée"; "HTTPS inspection is off" = "L'inspection HTTPS est désactivée"; @@ -782,8 +778,6 @@ "Camera" = "Caméra"; "Started" = "Démarrée"; "Unknown camera" = "Caméra inconnue"; -"%@ · nothing active" = "%@ · rien d'actif"; -"%@ · %lld active" = "%@ · %lld actifs"; "Watch camera activity" = "Surveiller l'activité de la caméra"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "Le Bluetooth, l'USB et l'activité de la caméra restent désactivés tant que vous ne les activez pas. Ils ne demandent aucune nouvelle autorisation : ils sont désactivés parce que leur surveillance élargit ce que Flowlight observe, et cette décision doit vous appartenir, plutôt qu'à une mise à jour.\n\nLe Bluetooth et l'USB indiquent ce qui est connecté. L'activité de la caméra indique si chaque caméra est active, pas quelle app l'utilise. Flowlight n'ouvre jamais la caméra et ne lit aucune image."; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "Aucun contenu ni attribution inventée. Le Bluetooth et l'USB indiquent les connexions, tandis que la surveillance de la caméra lit uniquement l'état actif de chaque appareil. macOS ne révèle ici publiquement aucune identité de l'app qui utilise la caméra : Flowlight nomme donc la caméra, ne devine jamais l'app et ne lit aucune image."; @@ -1035,7 +1029,6 @@ "The network is quiet" = "Le réseau est calme"; "No app has sent or received data in the last few seconds." = "Aucune app n'a envoyé ou reçu de données ces dernières secondes."; "Waiting for traffic data" = "En attente de données de trafic"; -"Open Capture Settings" = "Ouvrir les réglages de capture"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "Répondre soi-même à un point de terminaison choisi au lieu de laisser la requête atteindre le serveur — un 500, une limite de débit, un corps mal formé ou une longue attente — et observer ce que l'agent en fait."; @@ -1257,8 +1250,8 @@ "Until I resume" = "Jusqu'à la reprise"; "Pause Blocking" = "Suspendre le blocage"; "Stand every rule down for a while, without deleting anything" = "Mettre toutes les règles en veille un moment, sans rien supprimer"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "Une règle est surveillée mais pas appliquée. Seule l'extension réseau se trouve sur le trajet des données et peut refuser une connexion ; l'échantillonneur nettop compte le trafic après coup. Sélectionner l'extension réseau dans Capture pour appliquer ces règles."; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "%lld règles sont surveillées mais pas appliquées. Seule l'extension réseau se trouve sur le trajet des données et peut refuser une connexion ; l'échantillonneur nettop compte le trafic après coup. Sélectionner l'extension réseau dans Capture pour appliquer ces règles."; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "Une règle est surveillée mais pas appliquée. Seule l'extension réseau se trouve sur le trajet des données et peut refuser une connexion ; l'échantillonneur nettop compte le trafic après coup. Sélectionner l'extension réseau dans Sources › Collecteur pour appliquer ces règles."; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld règles sont surveillées mais pas appliquées. Seule l'extension réseau se trouve sur le trajet des données et peut refuser une connexion ; l'échantillonneur nettop compte le trafic après coup. Sélectionner l'extension réseau dans Sources › Collecteur pour appliquer ces règles."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Une règle est surveillée mais pas appliquée — une règle qui nomme un chemin ne peut être mise en correspondance que par l'inspection HTTPS, et celle-ci est désactivée."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld règles sont surveillées mais pas appliquées — une règle qui nomme un chemin ne peut être mise en correspondance que par l'inspection HTTPS, et celle-ci est désactivée."; "In force" = "En vigueur"; @@ -1308,7 +1301,7 @@ "Apply a Template" = "Appliquer un modèle"; "%@ · in force at once" = "%@ · en vigueur immédiatement"; "%@ · shown first" = "%@ · à examiner d'abord"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "Chacun écrit des règles ordinaires dans la liste, où elles peuvent être lues, désactivées ou supprimées comme les autres. Refuser une connexion est le travail de l'extension réseau : avec l'échantillonneur nettop sélectionné dans Capture, ces règles sont surveillées plutôt qu'appliquées."; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "Chacun écrit des règles ordinaires dans la liste, où elles peuvent être lues, désactivées ou supprimées comme les autres. Refuser une connexion est le travail de l'extension réseau : avec l'échantillonneur nettop sélectionné dans Sources › Collecteur, ces règles sont surveillées plutôt qu'appliquées."; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "Facultatif. Laissé vide, cela s'applique à tout ce qui se passe sur le Mac ; nommer une app restreint la règle à cette app et aux outils qu'elle a lancés."; "A domain, an IP address, or a range" = "Un domaine, une adresse IP ou une plage"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "Celles-ci nomment une méthode HTTP : seule l'inspection HTTPS peut les appliquer. Quand elle est désactivée, elles restent dans la liste à surveiller."; diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings index 15b09a7d..eebba212 100644 --- a/Flowlight/Localization/it.lproj/Localizable.strings +++ b/Flowlight/Localization/it.lproj/Localizable.strings @@ -71,7 +71,6 @@ "Devices" = "Dispositivi"; "Coverage" = "Copertura"; "Inspect" = "Ispeziona"; -"Capture" = "Acquisizione"; "Traffic" = "Traffico"; "Investigate" = "Analizza"; "Control" = "Controllo"; @@ -87,6 +86,7 @@ "Questions & Answers" = "Domande e risposte"; "What Flowlight Reads on This Mac" = "Cosa legge Flowlight su questo Mac"; "Privacy" = "Privacy"; +"Data & Privacy" = "Dati e privacy"; "Report an Issue" = "Segnala un problema"; "Turn Focus Off" = "Disattiva Focus"; "Turn Focus On" = "Attiva Focus"; @@ -634,8 +634,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "Eliminare tutto il traffico registrato, le linee di base e gli avvisi?"; "Delete everything" = "Elimina tutto"; -"nettop sampler · the extension didn't answer" = "Campionatore nettop · l'estensione non ha risposto"; -"Network Extension · unavailable" = "Estensione di rete · non disponibile"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Impossibile raggiungere l’estensione del filtro. Abilitala in Filtro contenuti, quindi premi Aggiorna."; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — abilita Flowlight in Filtro contenuti, quindi premi Aggiorna."; "%@ — the content filter is unavailable" = "%@ — il filtro contenuti non è disponibile"; @@ -644,8 +642,6 @@ "Reconnect to the content filter" = "Riconnetti al filtro contenuti"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Se una VPN o un agente di sicurezza occupa già quello slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon e simili lo utilizzano — il filtro di Flowlight non può ricevere traffico finché tale filtro non viene disabilitato."; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "Il filtro non risponde, quindi Flowlight non riceve traffico. Abilitalo in Filtro contenuti, quindi premi Aggiorna."; -"%@ · receiving" = "%@ · ricezione in corso"; -"%@ · nothing arriving yet" = "%@ · ancora nessun dato in arrivo"; "Source" = "Origine"; "Content filter" = "Filtro contenuti"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "Osserva i flussi TCP e UDP ammessi nel momento in cui si aprono. Richiede l'entitlement Network Extension e la tua approvazione."; @@ -702,7 +698,7 @@ "Without this, most traffic shows only an IP address or network owner." = "Senza questo, la maggior parte del traffico mostra solo un indirizzo IP o il proprietario della rete."; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "Flowlight legge le risposte DNS e i nomi server TLS sull'interfaccia di rete principale per dare un nome alle connessioni."; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "Il contenuto dei pacchetti non viene mai memorizzato né inviato da nessuna parte. Vengono conservati solo i nomi host."; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "macOS consente solo agli amministratori di leggere i pacchetti, quindi la password viene richiesta una sola volta. Puoi revocare l'accesso in qualsiasi momento in Acquisizione."; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "macOS consente solo agli amministratori di leggere i pacchetti, quindi la password viene richiesta una sola volta. Puoi revocare l'accesso in qualsiasi momento in Origini › Collector."; "Identify network owners when no hostname is available" = "Identifica i proprietari della rete quando non è disponibile un nome host"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "Attivo per impostazione predefinita. Invia gli IP pubblici di destinazione al servizio DNS di Team Cymru. Disattivalo per mantenere queste ricerche sul tuo Mac."; "Not Now" = "Non ora"; @@ -733,8 +729,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "Ancora nulla di registrato"; +"Nothing recorded in the last hour" = "Nessun dato registrato nell'ultima ora"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "La copertura è calcolata sul traffico che Flowlight ha già registrato. Lascialo in esecuzione per un momento."; -"%@ of traffic fully accounted for" = "%@ del traffico interamente contabilizzato"; "How much of this Mac is covered" = "Quanta parte di questo Mac è coperta"; "HTTPS inspection is on" = "L'ispezione HTTPS è attiva"; "HTTPS inspection is off" = "L'ispezione HTTPS è disattivata"; @@ -782,8 +778,6 @@ "Camera" = "Fotocamera"; "Started" = "Avviata"; "Unknown camera" = "Fotocamera sconosciuta"; -"%@ · nothing active" = "%@ · nulla di attivo"; -"%@ · %lld active" = "%@ · %lld attivi"; "Watch camera activity" = "Monitora l'attività della fotocamera"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "Bluetooth, USB e l'attività della fotocamera restano disattivati finché non li attivi. Non richiedono nuovi permessi: sono disattivati perché monitorarli amplia ciò che Flowlight osserva, e questa decisione dovrebbe spettare a te, non a un aggiornamento.\n\nBluetooth e USB mostrano ciò che è collegato. L'attività della fotocamera indica se ogni fotocamera è attiva, non quale app la sta usando. Flowlight non apre mai la fotocamera e non legge fotogrammi."; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "Nessun contenuto e nessuna attribuzione inventata. Bluetooth e USB mostrano i collegamenti, mentre il monitoraggio della fotocamera legge solo lo stato attivo di ciascun dispositivo. macOS non rivela pubblicamente quale app usa la fotocamera, quindi Flowlight indica la fotocamera, non indovina mai l'app e non legge fotogrammi."; @@ -1035,7 +1029,6 @@ "The network is quiet" = "La rete è tranquilla"; "No app has sent or received data in the last few seconds." = "Nessuna app ha inviato o ricevuto dati negli ultimi secondi."; "Waiting for traffic data" = "In attesa dei dati sul traffico"; -"Open Capture Settings" = "Apri le impostazioni di acquisizione"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "Rispondi tu a un endpoint scelto invece di lasciare che la richiesta raggiunga il server — un 500, un limite di frequenza, un corpo malformato o una lunga attesa — e osserva come reagisce l'agente."; @@ -1257,8 +1250,8 @@ "Until I resume" = "Finché non riprendo"; "Pause Blocking" = "Sospendi il blocco"; "Stand every rule down for a while, without deleting anything" = "Metti in pausa tutte le regole per un po', senza eliminare nulla"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "Una regola viene osservata ma non applicata. Solo l'estensione di rete si trova sul percorso dei dati e può rifiutare una connessione; il campionatore nettop conta il traffico a posteriori. Seleziona l'estensione di rete in Acquisizione per applicare queste regole."; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "%lld regole vengono osservate ma non applicate. Solo l'estensione di rete si trova sul percorso dei dati e può rifiutare una connessione; il campionatore nettop conta il traffico a posteriori. Seleziona l'estensione di rete in Acquisizione per applicare queste regole."; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "Una regola viene osservata ma non applicata. Solo l'estensione di rete si trova sul percorso dei dati e può rifiutare una connessione; il campionatore nettop conta il traffico a posteriori. Seleziona l'estensione di rete in Origini › Collector per applicare queste regole."; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld regole vengono osservate ma non applicate. Solo l'estensione di rete si trova sul percorso dei dati e può rifiutare una connessione; il campionatore nettop conta il traffico a posteriori. Seleziona l'estensione di rete in Origini › Collector per applicare queste regole."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Una regola viene osservata ma non applicata: una regola che indica un percorso può essere confrontata solo dall'ispezione HTTPS, che è disattivata."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld regole vengono osservate ma non applicate: una regola che indica un percorso può essere confrontata solo dall'ispezione HTTPS, che è disattivata."; "In force" = "In vigore"; @@ -1308,7 +1301,7 @@ "Apply a Template" = "Applica un modello"; "%@ · in force at once" = "%@ · in vigore subito"; "%@ · shown first" = "%@ · mostrate prima"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "Ognuno scrive regole normali nell'elenco, dove possono essere lette, disattivate o eliminate come tutte le altre. Rifiutare una connessione è compito dell'estensione di rete, quindi con il campionatore nettop selezionato in Acquisizione queste regole vengono osservate anziché applicate."; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "Ognuno scrive regole normali nell'elenco, dove possono essere lette, disattivate o eliminate come tutte le altre. Rifiutare una connessione è compito dell'estensione di rete, quindi con il campionatore nettop selezionato in Origini › Collector queste regole vengono osservate anziché applicate."; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "Facoltativo. Se lasciato vuoto si applica a tutto il Mac; indicare un'app lo limita a quell'app e agli strumenti che ha avviato."; "A domain, an IP address, or a range" = "Un dominio, un indirizzo IP o un intervallo"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "Queste indicano un metodo HTTP, quindi solo l'ispezione HTTPS può applicarle. Con l'ispezione disattivata restano nell'elenco a osservare."; diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings index 5b85ea84..4f3e1460 100644 --- a/Flowlight/Localization/ja.lproj/Localizable.strings +++ b/Flowlight/Localization/ja.lproj/Localizable.strings @@ -71,7 +71,6 @@ "Devices" = "デバイス"; "Coverage" = "カバー範囲"; "Inspect" = "インスペクト"; -"Capture" = "キャプチャ"; "Traffic" = "トラフィック"; "Investigate" = "調査"; "Control" = "制御"; @@ -87,6 +86,7 @@ "Questions & Answers" = "よくある質問"; "What Flowlight Reads on This Mac" = "Flowlight がこの Mac で読み取るもの"; "Privacy" = "プライバシー"; +"Data & Privacy" = "データとプライバシー"; "Report an Issue" = "問題を報告"; "Turn Focus Off" = "フォーカスをオフにする"; "Turn Focus On" = "フォーカスをオンにする"; @@ -634,8 +634,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "記録したトラフィック・基準値・アラートをすべて削除しますか?"; "Delete everything" = "すべて削除"; -"nettop sampler · the extension didn't answer" = "nettop サンプラー · 機能拡張が応答しませんでした"; -"Network Extension · unavailable" = "ネットワーク機能拡張 · 利用不可"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "フィルター機能拡張に接続できませんでした。コンテンツフィルターで有効にしてから、更新を押してください。"; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — コンテンツフィルターで Flowlight を有効にしてから、更新を押してください。"; "%@ — the content filter is unavailable" = "%@ — コンテンツフィルターを利用できません"; @@ -644,8 +642,6 @@ "Reconnect to the content filter" = "コンテンツフィルターに再接続"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "VPN やセキュリティエージェントがそのスロットを使用している場合(Palo Alto Networks GlobalProtect、CrowdStrike Falcon など)、そのフィルターを無効にするまで Flowlight のフィルターはトラフィックを受信できません。"; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "フィルターが応答していないため、Flowlight はトラフィックを受信していません。コンテンツフィルターで有効にしてから、更新を押してください。"; -"%@ · receiving" = "%@ · 受信中"; -"%@ · nothing arriving yet" = "%@ · まだ届いていません"; "Source" = "ソース"; "Content filter" = "コンテンツフィルタ"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "対象となる TCP と UDP のフローを、開かれた時点で観測します。ネットワーク機能拡張の entitlement と、あなたの承認が必要です。"; @@ -702,7 +698,7 @@ "Without this, most traffic shows only an IP address or network owner." = "これがないと、ほとんどのトラフィックは IP アドレスかネットワーク所有者しか表示されません。"; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "Flowlight は接続に名前を付けるため、主ネットワークインターフェイスで DNS の応答と TLS のサーバ名を読み取ります。"; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "パケットの内容は保存も送信もされません。保持するのはホスト名だけです。"; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "macOS はパケットの読み取りを管理者にしか許可しないため、パスワードを 1 回確認します。キャプチャでいつでも解除できます。"; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "macOS はパケットの読み取りを管理者にしか許可しないため、パスワードを 1 回確認します。「ソース › コレクタ」でいつでも解除できます。"; "Identify network owners when no hostname is available" = "ホスト名がない場合にネットワーク所有者を特定"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "デフォルトでオンです。パブリックな接続先 IP を Team Cymru の DNS サービスに送信します。オフにすると、この照会はこの Mac 内だけで行われます。"; "Not Now" = "今はしない"; @@ -733,8 +729,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "まだ何も記録されていません"; +"Nothing recorded in the last hour" = "過去 1 時間には何も記録されていません"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "カバー範囲は、Flowlight がすでに記録したトラフィックから算出します。しばらく動かしたままにしてください。"; -"%@ of traffic fully accounted for" = "トラフィックの %@ を完全に把握"; "How much of this Mac is covered" = "この Mac をどこまでカバーできているか"; "HTTPS inspection is on" = "HTTPS インスペクションはオンです"; "HTTPS inspection is off" = "HTTPS インスペクションはオフです"; @@ -782,8 +778,6 @@ "Camera" = "カメラ"; "Started" = "開始"; "Unknown camera" = "不明なカメラ"; -"%@ · nothing active" = "%@ · アクティブなし"; -"%@ · %lld active" = "%@ · %lld 台がアクティブ"; "Watch camera activity" = "カメラの動作を監視"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "Bluetooth、USB、カメラの動作は、オンにするまで監視されません。新しい権限は必要ありません。オフになっているのは、監視すると Flowlight が見る範囲が広がるためであり、それはアップデートではなく、あなたが決めることです。\n\nBluetooth と USB では接続中のものを表示します。カメラの動作では各カメラがアクティブかどうかを表示しますが、使用中のアプリは表示しません。Flowlight がカメラを開いたり、フレームを読み取ったりすることはありません。"; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "内容を読み取ることも、根拠のないアプリへの関連付けをすることもありません。Bluetooth と USB では接続を表示し、カメラの監視では各デバイスがアクティブかどうかだけを読み取ります。macOS はここでカメラを使用中のアプリを公開していないため、Flowlight はカメラ名のみを示し、アプリを推測せず、フレームも読み取りません。"; @@ -1035,7 +1029,6 @@ "The network is quiet" = "ネットワークは静かです"; "No app has sent or received data in the last few seconds." = "この数秒間にデータを送受信したアプリはありません。"; "Waiting for traffic data" = "トラフィックデータを待っています"; -"Open Capture Settings" = "キャプチャの設定を開く"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "リクエストをサーバに届けず、選んだエンドポイントに自分で応答します。500、レート制限、壊れた本文、長い待ち時間などを返して、エージェントがどう振る舞うかを確かめられます。"; @@ -1257,8 +1250,8 @@ "Until I resume" = "再開するまで"; "Pause Blocking" = "ブロックを一時停止"; "Stand every rule down for a while, without deleting anything" = "何も削除せずに、すべてのルールをしばらく止めます"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "1 件のルールは監視されているだけで、実行されていません。データ経路に入って接続を拒否できるのはネットワーク機能拡張だけで、nettop サンプラーは事後にトラフィックを数えます。これらのルールを実行するには、キャプチャでネットワーク機能拡張を選んでください。"; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "%lld 件のルールは監視されているだけで、実行されていません。データ経路に入って接続を拒否できるのはネットワーク機能拡張だけで、nettop サンプラーは事後にトラフィックを数えます。これらのルールを実行するには、キャプチャでネットワーク機能拡張を選んでください。"; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "1 件のルールは監視されているだけで、実行されていません。データ経路に入って接続を拒否できるのはネットワーク機能拡張だけで、nettop サンプラーは事後にトラフィックを数えます。これらのルールを実行するには、ソース › コレクターでネットワーク機能拡張を選んでください。"; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld 件のルールは監視されているだけで、実行されていません。データ経路に入って接続を拒否できるのはネットワーク機能拡張だけで、nettop サンプラーは事後にトラフィックを数えます。これらのルールを実行するには、ソース › コレクターでネットワーク機能拡張を選んでください。"; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "1 件のルールは監視されているだけで、実行されていません。パスを指定したルールを照合できるのは HTTPS インスペクションだけで、それがオフになっています。"; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld 件のルールは監視されているだけで、実行されていません。パスを指定したルールを照合できるのは HTTPS インスペクションだけで、それがオフになっています。"; "In force" = "有効"; @@ -1308,7 +1301,7 @@ "Apply a Template" = "テンプレートを適用"; "%@ · in force at once" = "%@ · すぐに有効"; "%@ · shown first" = "%@ · 適用前に確認"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "どのテンプレートも、ふつうのルールをリストに書き込みます。ほかのルールと同じように、内容を読み、オフにし、削除できます。接続を拒否するのはネットワーク機能拡張の役目のため、キャプチャで nettop サンプラーを選んでいる場合は、実行ではなく監視にとどまります。"; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "どのテンプレートも、ふつうのルールをリストに書き込みます。ほかのルールと同じように、内容を読み、オフにし、削除できます。接続を拒否するのはネットワーク機能拡張の役目のため、ソース › コレクターで nettop サンプラーを選んでいる場合は、実行ではなく監視にとどまります。"; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "省略できます。空のままにするとこの Mac 全体に適用され、アプリを指定すると、そのアプリとそれが起動したツールだけに絞られます。"; "A domain, an IP address, or a range" = "ドメイン、IP アドレス、または範囲"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "これらは HTTP メソッドを指定するため、実行できるのは HTTPS インスペクションだけです。オフの場合は、リストの中で監視するだけになります。"; diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings index 253bef7f..6ad2abc5 100644 --- a/Flowlight/Localization/ko.lproj/Localizable.strings +++ b/Flowlight/Localization/ko.lproj/Localizable.strings @@ -71,7 +71,6 @@ "Devices" = "기기"; "Coverage" = "포착 범위"; "Inspect" = "검사"; -"Capture" = "캡처"; "Traffic" = "트래픽"; "Investigate" = "분석"; "Control" = "제어"; @@ -87,6 +86,7 @@ "Questions & Answers" = "질문과 답변"; "What Flowlight Reads on This Mac" = "Flowlight가 이 Mac에서 읽는 항목"; "Privacy" = "개인 정보 보호"; +"Data & Privacy" = "데이터 및 개인 정보 보호"; "Report an Issue" = "문제 보고"; "Turn Focus Off" = "집중 끄기"; "Turn Focus On" = "집중 켜기"; @@ -657,8 +657,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "기록된 트래픽, 기준선, 알림을 모두 삭제할까요?"; "Delete everything" = "모두 삭제"; -"nettop sampler · the extension didn't answer" = "nettop 샘플러 · 확장 프로그램이 응답하지 않았습니다"; -"Network Extension · unavailable" = "네트워크 확장 · 사용할 수 없음"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "필터 확장에 연결할 수 없습니다. 콘텐츠 필터에서 활성화한 다음 새로 고침을 누르세요."; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — 콘텐츠 필터에서 Flowlight를 활성화한 다음 새로 고침을 누르세요."; "%@ — the content filter is unavailable" = "%@ — 콘텐츠 필터를 사용할 수 없습니다"; @@ -667,8 +665,6 @@ "Reconnect to the content filter" = "콘텐츠 필터에 다시 연결"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "VPN 또는 보안 에이전트가 이미 해당 슬롯을 사용 중이라면(Palo Alto Networks GlobalProtect, CrowdStrike Falcon 등), 그 필터를 비활성화할 때까지 Flowlight 필터는 트래픽을 받을 수 없습니다."; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "필터가 응답하지 않아 Flowlight가 트래픽을 받지 못하고 있습니다. 콘텐츠 필터에서 활성화한 다음 새로 고침을 누르세요."; -"%@ · receiving" = "%@ · 수신 중"; -"%@ · nothing arriving yet" = "%@ · 아직 들어오는 데이터 없음"; "Source" = "소스"; "Content filter" = "콘텐츠 필터"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "대상이 되는 TCP·UDP 흐름이 열리는 순간 관찰합니다. 네트워크 확장 프로그램 권한과 사용자 승인이 필요합니다."; @@ -725,7 +721,7 @@ "Without this, most traffic shows only an IP address or network owner." = "이 기능이 없으면 대부분의 트래픽은 IP 주소나 네트워크 소유자만 표시됩니다."; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "Flowlight는 연결에 이름을 붙이기 위해 주 네트워크 인터페이스에서 DNS 응답과 TLS 서버 이름을 읽습니다."; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "패킷 내용은 저장하지도, 어디로 전송하지도 않습니다. 호스트 이름만 보관합니다."; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "macOS는 관리자만 패킷을 읽을 수 있게 하므로 암호를 한 번 요청합니다. 캡처에서 언제든 다시 해제할 수 있습니다."; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "macOS는 관리자만 패킷을 읽을 수 있게 하므로 암호를 한 번 요청합니다. 소스 › 수집기에서 언제든 다시 해제할 수 있습니다."; "Identify network owners when no hostname is available" = "호스트 이름이 없을 때 네트워크 소유자 식별"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "기본적으로 켜져 있습니다. 공인 목적지 IP를 Team Cymru의 DNS 서비스로 보냅니다. 끄면 이 조회를 Mac 안에서만 수행합니다."; "Not Now" = "나중에"; @@ -756,8 +752,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "아직 기록된 것이 없습니다"; +"Nothing recorded in the last hour" = "최근 한 시간 동안 기록된 것이 없습니다"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "포착 범위는 Flowlight가 이미 기록한 트래픽으로 계산합니다. 잠시 그대로 실행해 두세요."; -"%@ of traffic fully accounted for" = "트래픽의 %@를 완전히 파악"; "How much of this Mac is covered" = "이 Mac을 어디까지 포착하고 있는지"; "HTTPS inspection is on" = "HTTPS 검사가 켜져 있습니다"; "HTTPS inspection is off" = "HTTPS 검사가 꺼져 있습니다"; @@ -805,8 +801,6 @@ "Camera" = "카메라"; "Started" = "시작됨"; "Unknown camera" = "알 수 없는 카메라"; -"%@ · nothing active" = "%@ · 활성 항목 없음"; -"%@ · %lld active" = "%@ · %lld개 활성"; "Watch camera activity" = "카메라 활동 감시"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "Bluetooth, USB 및 카메라 활동은 직접 켤 때까지 꺼져 있습니다. 새 권한은 필요하지 않습니다. 감시하면 Flowlight가 들여다보는 범위가 넓어지므로, 업데이트가 아니라 사용자가 결정하도록 꺼 두었습니다.\n\nBluetooth와 USB는 연결된 항목을 보여 줍니다. 카메라 활동은 각 카메라의 활성 여부만 보여 주며 어떤 앱이 사용하는지는 보여 주지 않습니다. Flowlight는 카메라를 열거나 프레임을 읽지 않습니다."; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "내용을 읽거나 근거 없이 앱에 귀속하지 않습니다. Bluetooth와 USB는 연결 상태를 보여 주고, 카메라 감시는 각 기기의 활성 상태만 읽습니다. macOS는 여기서 카메라를 사용하는 앱의 신원을 공개하지 않으므로 Flowlight는 카메라 이름만 표시하고 앱을 추측하거나 프레임을 읽지 않습니다."; @@ -1058,7 +1052,6 @@ "The network is quiet" = "네트워크가 조용합니다"; "No app has sent or received data in the last few seconds." = "최근 몇 초 동안 데이터를 주고받은 앱이 없습니다."; "Waiting for traffic data" = "트래픽 데이터를 기다리는 중"; -"Open Capture Settings" = "캡처 설정 열기"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "요청이 서버에 닿기 전에 원하는 엔드포인트에 직접 응답합니다. 500 오류, 속도 제한, 잘못된 본문, 긴 지연 등을 돌려주고 에이전트가 어떻게 대응하는지 지켜보세요."; @@ -1280,8 +1273,8 @@ "Until I resume" = "다시 시작할 때까지"; "Pause Blocking" = "차단 일시 중지"; "Stand every rule down for a while, without deleting anything" = "아무것도 삭제하지 않고 모든 규칙을 잠시 쉬게 합니다"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "규칙 1개가 관찰만 되고 실행되지는 않습니다. 데이터 경로에 자리해 연결을 거부할 수 있는 것은 네트워크 확장 프로그램뿐이며, nettop 샘플러는 트래픽을 사후에 집계합니다. 이 규칙을 적용하려면 캡처에서 네트워크 확장 프로그램을 선택하세요."; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "규칙 %lld개가 관찰만 되고 실행되지는 않습니다. 데이터 경로에 자리해 연결을 거부할 수 있는 것은 네트워크 확장 프로그램뿐이며, nettop 샘플러는 트래픽을 사후에 집계합니다. 이 규칙을 적용하려면 캡처에서 네트워크 확장 프로그램을 선택하세요."; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "규칙 1개가 관찰만 되고 실행되지는 않습니다. 데이터 경로에 자리해 연결을 거부할 수 있는 것은 네트워크 확장 프로그램뿐이며, nettop 샘플러는 트래픽을 사후에 집계합니다. 이 규칙을 적용하려면 소스 › 컬렉터에서 네트워크 확장 프로그램을 선택하세요."; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "규칙 %lld개가 관찰만 되고 실행되지는 않습니다. 데이터 경로에 자리해 연결을 거부할 수 있는 것은 네트워크 확장 프로그램뿐이며, nettop 샘플러는 트래픽을 사후에 집계합니다. 이 규칙을 적용하려면 소스 › 컬렉터에서 네트워크 확장 프로그램을 선택하세요."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "규칙 1개가 관찰만 되고 실행되지는 않습니다. 경로를 지정한 규칙은 HTTPS 검사로만 일치시킬 수 있는데, 검사가 꺼져 있습니다."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "규칙 %lld개가 관찰만 되고 실행되지는 않습니다. 경로를 지정한 규칙은 HTTPS 검사로만 일치시킬 수 있는데, 검사가 꺼져 있습니다."; "In force" = "적용 중"; @@ -1331,7 +1324,7 @@ "Apply a Template" = "템플릿 적용"; "%@ · in force at once" = "%@ · 즉시 적용"; "%@ · shown first" = "%@ · 먼저 표시"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "각 템플릿은 일반 규칙을 목록에 써 넣을 뿐이며, 다른 규칙과 똑같이 확인하고 끄고 삭제할 수 있습니다. 연결을 실제로 거부하는 일은 네트워크 확장 프로그램의 몫이므로, 캡처에서 nettop 샘플러를 선택한 상태에서는 이 규칙들이 실행되지 않고 관찰만 됩니다."; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "각 템플릿은 일반 규칙을 목록에 써 넣을 뿐이며, 다른 규칙과 똑같이 확인하고 끄고 삭제할 수 있습니다. 연결을 실제로 거부하는 일은 네트워크 확장 프로그램의 몫이므로, 소스 › 컬렉터에서 nettop 샘플러를 선택한 상태에서는 이 규칙들이 실행되지 않고 관찰만 됩니다."; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "선택 사항입니다. 비워 두면 이 Mac 전체에 적용되고, 앱을 지정하면 그 앱과 그 앱이 실행한 도구로 범위가 좁혀집니다."; "A domain, an IP address, or a range" = "도메인, IP 주소 또는 범위"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "이 규칙은 HTTP 메서드를 지정하므로 HTTPS 검사만 실행할 수 있습니다. 검사가 꺼져 있으면 목록에서 관찰만 합니다."; diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings index 3c51cac3..324de56f 100644 --- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings +++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings @@ -71,7 +71,6 @@ "Devices" = "Dispositivos"; "Coverage" = "Cobertura"; "Inspect" = "Inspecionar"; -"Capture" = "Captura"; "Traffic" = "Tráfego"; "Investigate" = "Investigar"; "Control" = "Controlo"; @@ -87,6 +86,7 @@ "Questions & Answers" = "Perguntas e respostas"; "What Flowlight Reads on This Mac" = "O que o Flowlight lê neste Mac"; "Privacy" = "Privacidade"; +"Data & Privacy" = "Dados e privacidade"; "Report an Issue" = "Comunicar um problema"; "Turn Focus Off" = "Desativar o Foco"; "Turn Focus On" = "Ativar o Foco"; @@ -657,8 +657,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "Eliminar todo o tráfego registado, as linhas de base e os alertas?"; "Delete everything" = "Eliminar tudo"; -"nettop sampler · the extension didn't answer" = "amostrador nettop · a extensão não respondeu"; -"Network Extension · unavailable" = "Extensão de rede · indisponível"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Não foi possível aceder à extensão do filtro. Ative-a em Filtro de conteúdo e prima Atualizar."; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — ative o Flowlight em Filtro de conteúdo e prima Atualizar."; "%@ — the content filter is unavailable" = "%@ — o filtro de conteúdo está indisponível"; @@ -667,8 +665,6 @@ "Reconnect to the content filter" = "Voltar a ligar ao filtro de conteúdo"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Se uma VPN ou um agente de segurança já ocupar esse espaço — Palo Alto Networks GlobalProtect, CrowdStrike Falcon e semelhantes utilizam-no — o filtro do Flowlight não pode receber tráfego até esse filtro ser desativado."; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "O filtro não está a responder, pelo que o Flowlight não recebe tráfego. Ative-o em Filtro de conteúdo e prima Atualizar."; -"%@ · receiving" = "%@ · a receber"; -"%@ · nothing arriving yet" = "%@ · ainda nada a chegar"; "Source" = "Origem"; "Content filter" = "Filtro de conteúdo"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "Observa os fluxos TCP e UDP elegíveis à medida que abrem. Requer o entitlement da extensão de rede e a sua aprovação."; @@ -725,7 +721,7 @@ "Without this, most traffic shows only an IP address or network owner." = "Sem isto, a maior parte do tráfego mostra apenas um endereço IP ou o proprietário da rede."; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "O Flowlight lê as respostas DNS e os nomes de servidor TLS na sua interface de rede principal para dar nome às ligações."; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "O conteúdo dos pacotes nunca é guardado nem enviado para nenhum sítio. Só são guardados os nomes de anfitrião."; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "O macOS só permite que os administradores leiam pacotes, por isso isto pede a sua palavra-passe uma vez. Pode removê-lo em qualquer momento em Captura."; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "O macOS só permite que os administradores leiam pacotes, por isso isto pede a sua palavra-passe uma vez. Pode removê-lo em qualquer momento em Origens › Coletor."; "Identify network owners when no hostname is available" = "Identificar os proprietários da rede quando não há nome de anfitrião"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "Ativado por predefinição. Envia os IPs públicos de destino para o serviço DNS da Team Cymru. Desative para manter estas consultas no seu Mac."; "Not Now" = "Não agora"; @@ -756,8 +752,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "Ainda não há nada registado"; +"Nothing recorded in the last hour" = "Nada registado na última hora"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "A cobertura é calculada a partir do tráfego que o Flowlight já registou. Deixe-o a correr por um momento."; -"%@ of traffic fully accounted for" = "%@ do tráfego totalmente contabilizado"; "How much of this Mac is covered" = "Quanto deste Mac está coberto"; "HTTPS inspection is on" = "A inspeção HTTPS está ativada"; "HTTPS inspection is off" = "A inspeção HTTPS está desativada"; @@ -805,8 +801,6 @@ "Camera" = "Câmara"; "Started" = "Iniciada"; "Unknown camera" = "Câmara desconhecida"; -"%@ · nothing active" = "%@ · nada ativo"; -"%@ · %lld active" = "%@ · %lld ativos"; "Watch camera activity" = "Vigiar a atividade da câmara"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "O Bluetooth, o USB e a atividade da câmara estão desativados até os ativar. Não precisam de novas permissões — estão desativados porque vigiá-los amplia o que o Flowlight observa, e essa decisão deve ser sua, não de uma atualização.\n\nO Bluetooth e o USB mostram o que está ligado. A atividade da câmara mostra se cada câmara está ativa, não que app a está a usar. O Flowlight nunca abre a câmara nem lê fotogramas."; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "Sem conteúdos nem atribuições inventadas. O Bluetooth e o USB mostram as ligações, enquanto a monitorização da câmara lê apenas o estado ativo de cada dispositivo. O macOS não revela aqui publicamente qual é a app que usa a câmara, por isso o Flowlight identifica a câmara, nunca adivinha a app e nunca lê fotogramas."; @@ -1058,7 +1052,6 @@ "The network is quiet" = "A rede está calma"; "No app has sent or received data in the last few seconds." = "Nenhuma app enviou ou recebeu dados nos últimos segundos."; "Waiting for traffic data" = "À espera de dados de tráfego"; -"Open Capture Settings" = "Abrir as definições de captura"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "Responda você a um endpoint escolhido em vez de deixar o pedido chegar ao servidor — um 500, um limite de pedidos, um corpo malformado ou uma espera longa — e veja o que o agente faz com isso."; @@ -1280,8 +1273,8 @@ "Until I resume" = "Até eu retomar"; "Pause Blocking" = "Pausar o bloqueio"; "Stand every rule down for a while, without deleting anything" = "Suspender todas as regras durante algum tempo, sem eliminar nada"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "Uma regra está a ser vigiada mas não aplicada. Só a extensão de rede está no caminho dos dados e pode recusar uma ligação; o amostrador nettop conta o tráfego depois do facto. Selecione a extensão de rede em Captura para aplicar estas regras."; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "%lld regras estão a ser vigiadas mas não aplicadas. Só a extensão de rede está no caminho dos dados e pode recusar uma ligação; o amostrador nettop conta o tráfego depois do facto. Selecione a extensão de rede em Captura para aplicar estas regras."; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "Uma regra está a ser vigiada mas não aplicada. Só a extensão de rede está no caminho dos dados e pode recusar uma ligação; o amostrador nettop conta o tráfego depois do facto. Selecione a extensão de rede em Origens › Coletor para aplicar estas regras."; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld regras estão a ser vigiadas mas não aplicadas. Só a extensão de rede está no caminho dos dados e pode recusar uma ligação; o amostrador nettop conta o tráfego depois do facto. Selecione a extensão de rede em Origens › Coletor para aplicar estas regras."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Uma regra está a ser vigiada mas não aplicada — uma regra que nomeia um caminho só pode ser correspondida pela inspeção HTTPS, e ela está desativada."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld regras estão a ser vigiadas mas não aplicadas — uma regra que nomeia um caminho só pode ser correspondida pela inspeção HTTPS, e ela está desativada."; "In force" = "Em vigor"; @@ -1331,7 +1324,7 @@ "Apply a Template" = "Aplicar um modelo"; "%@ · in force at once" = "%@ · em vigor de imediato"; "%@ · shown first" = "%@ · mostrado primeiro"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "Cada um escreve regras normais na lista, onde podem ser lidas, desativadas ou eliminadas como quaisquer outras. Recusar uma ligação é tarefa da extensão de rede, por isso, com o amostrador nettop selecionado em Captura, estas são vigiadas em vez de aplicadas."; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "Cada um escreve regras normais na lista, onde podem ser lidas, desativadas ou eliminadas como quaisquer outras. Recusar uma ligação é tarefa da extensão de rede, por isso, com o amostrador nettop selecionado em Origens › Coletor, estas são vigiadas em vez de aplicadas."; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "Opcional. Se ficar vazio, aplica-se a tudo no Mac; nomear uma app restringe-o a essa app e às ferramentas que ela iniciou."; "A domain, an IP address, or a range" = "Um domínio, um endereço IP, ou um intervalo"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "Estas nomeiam um método HTTP, por isso só a inspeção HTTPS as pode aplicar. Com ela desativada, ficam na lista a vigiar."; diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings index 5b3fd309..b0dafb5f 100644 --- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings @@ -71,7 +71,6 @@ "Devices" = "设备"; "Coverage" = "覆盖范围"; "Inspect" = "检查"; -"Capture" = "捕获"; "Traffic" = "流量"; "Investigate" = "调查"; "Control" = "管控"; @@ -87,6 +86,7 @@ "Questions & Answers" = "问题与解答"; "What Flowlight Reads on This Mac" = "Flowlight 在这台 Mac 上读取什么"; "Privacy" = "隐私"; +"Data & Privacy" = "数据与隐私"; "Report an Issue" = "报告问题"; "Turn Focus Off" = "关闭聚焦"; "Turn Focus On" = "开启聚焦"; @@ -657,8 +657,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "删除所有已记录的流量、基线和警报?"; "Delete everything" = "全部删除"; -"nettop sampler · the extension didn't answer" = "nettop 采样器 · 扩展没有响应"; -"Network Extension · unavailable" = "网络扩展 · 不可用"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "无法连接过滤器扩展。请在“内容过滤器”中启用它,然后点按“刷新”。"; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — 请在“内容过滤器”中启用 Flowlight,然后点按“刷新”。"; "%@ — the content filter is unavailable" = "%@ — 内容过滤器不可用"; @@ -667,8 +665,6 @@ "Reconnect to the content filter" = "重新连接内容过滤器"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "如果 VPN 或安全代理已占用该位置(Palo Alto Networks GlobalProtect、CrowdStrike Falcon 等都会使用它),则在停用该过滤器之前,Flowlight 的过滤器无法接收流量。"; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "过滤器没有响应,因此 Flowlight 未收到流量。请在“内容过滤器”中启用它,然后点按“刷新”。"; -"%@ · receiving" = "%@ · 正在接收"; -"%@ · nothing arriving yet" = "%@ · 尚无数据到达"; "Source" = "来源"; "Content filter" = "内容过滤器"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "在符合条件的 TCP 和 UDP 流建立时进行观察。需要网络扩展授权和你的批准。"; @@ -725,7 +721,7 @@ "Without this, most traffic shows only an IP address or network owner." = "不开启的话,大多数流量只会显示 IP 地址或网络所有者。"; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "Flowlight 读取你主网络接口上的 DNS 应答和 TLS 服务器名称,用来为连接命名。"; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "数据包内容从不保存,也从不发往任何地方。只保留主机名。"; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "macOS 只允许管理员读取数据包,因此这里会要求你输入一次密码。你可以随时在“捕获”中移除它。"; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "macOS 只允许管理员读取数据包,因此这里会要求你输入一次密码。你可以随时在“来源 › 收集器”中移除它。"; "Identify network owners when no hostname is available" = "在没有主机名时识别网络所有者"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "默认开启。会把公网目标 IP 发送到 Team Cymru 的 DNS 服务。关闭后,这些查询只在你的 Mac 上进行。"; "Not Now" = "以后再说"; @@ -756,8 +752,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "尚未记录到任何内容"; +"Nothing recorded in the last hour" = "最近一小时内没有记录"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "覆盖范围是根据 Flowlight 已经记录的流量算出来的。请让它再运行一会儿。"; -"%@ of traffic fully accounted for" = "%@ 的流量完全有据可查"; "How much of this Mac is covered" = "这台 Mac 被覆盖了多少"; "HTTPS inspection is on" = "HTTPS 检查已开启"; "HTTPS inspection is off" = "HTTPS 检查已关闭"; @@ -805,8 +801,6 @@ "Camera" = "摄像头"; "Started" = "已启动"; "Unknown camera" = "未知摄像头"; -"%@ · nothing active" = "%@ · 没有活动项"; -"%@ · %lld active" = "%@ · %lld 个活动项"; "Watch camera activity" = "监视摄像头活动"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "蓝牙、USB 和摄像头活动在你开启之前一直关闭。它们不需要新的权限 —— 默认关闭是因为监视它们会扩大 Flowlight 的观察范围,而这应该由你决定,不应由某次更新替你决定。\n\n蓝牙和 USB 会显示连接的设备。摄像头活动会显示每个摄像头是否处于活动状态,但不会显示哪个应用正在使用它。Flowlight 从不打开摄像头,也不读取画面。"; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "不读取内容,也不凭空推断归属。蓝牙和 USB 显示连接状态,摄像头监视只读取每台设备是否处于活动状态。macOS 不在这里公开使用摄像头的应用,因此 Flowlight 只标明摄像头,从不猜测应用,也不读取画面。"; @@ -1058,7 +1052,6 @@ "The network is quiet" = "网络很安静"; "No app has sent or received data in the last few seconds." = "最近几秒内没有应用收发数据。"; "Waiting for traffic data" = "正在等待流量数据"; -"Open Capture Settings" = "打开捕获设置"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "自行响应指定的端点,而不让请求到达服务器 —— 返回 500、限流、格式错误的正文,或是长时间等待 —— 然后看看代理会如何应对。"; @@ -1280,8 +1273,8 @@ "Until I resume" = "直到我手动恢复"; "Pause Blocking" = "暂停阻止"; "Stand every rule down for a while, without deleting anything" = "让所有规则暂时停止生效,不删除任何内容"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "有一条规则只是被监视,而没有被执行。只有网络扩展位于数据路径上,能够拒绝连接;nettop 采样器是事后计数。请在“捕获”中选择网络扩展,以执行这些规则。"; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "%lld 条规则只是被监视,而没有被执行。只有网络扩展位于数据路径上,能够拒绝连接;nettop 采样器是事后计数。请在“捕获”中选择网络扩展,以执行这些规则。"; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "有一条规则只是被监视,而没有被执行。只有网络扩展位于数据路径上,能够拒绝连接;nettop 采样器是事后计数。请在“来源 › 收集器”中选择网络扩展,以执行这些规则。"; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld 条规则只是被监视,而没有被执行。只有网络扩展位于数据路径上,能够拒绝连接;nettop 采样器是事后计数。请在“来源 › 收集器”中选择网络扩展,以执行这些规则。"; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "有一条规则只是被监视,而没有被执行 —— 指明路径的规则只能由 HTTPS 检查匹配,而它已关闭。"; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld 条规则只是被监视,而没有被执行 —— 指明路径的规则只能由 HTTPS 检查匹配,而它已关闭。"; "In force" = "生效中"; @@ -1331,7 +1324,7 @@ "Apply a Template" = "应用模板"; "%@ · in force at once" = "%@ · 立即生效"; "%@ · shown first" = "%@ · 先供你确认"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "每个模板都会向列表中写入普通规则,你可以像对待其他规则一样查看、关闭或删除它们。拒绝连接是网络扩展的工作,因此在“捕获”中选择 nettop 采样器时,这些规则只是被监视,而不会被执行。"; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "每个模板都会向列表中写入普通规则,你可以像对待其他规则一样查看、关闭或删除它们。拒绝连接是网络扩展的工作,因此在“来源 › 收集器”中选择 nettop 采样器时,这些规则只是被监视,而不会被执行。"; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "可选。留空则适用于这台 Mac 上的一切;指明某个应用会把范围收窄到该应用以及它启动的工具。"; "A domain, an IP address, or a range" = "域名、IP 地址或地址段"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "这些规则指明了 HTTP 方法,因此只有 HTTPS 检查才能执行它们。检查关闭时,它们只会留在列表里监视。"; diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings index 0c2468c6..9fb87054 100644 --- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings @@ -71,7 +71,6 @@ "Devices" = "裝置"; "Coverage" = "涵蓋範圍"; "Inspect" = "檢查"; -"Capture" = "擷取"; "Traffic" = "流量"; "Investigate" = "調查"; "Control" = "管控"; @@ -87,6 +86,7 @@ "Questions & Answers" = "常見問答"; "What Flowlight Reads on This Mac" = "Flowlight 在這台 Mac 上讀取哪些資料"; "Privacy" = "隱私權"; +"Data & Privacy" = "資料與隱私權"; "Report an Issue" = "回報問題"; "Turn Focus Off" = "關閉焦點"; "Turn Focus On" = "開啟焦點"; @@ -657,8 +657,6 @@ /* UI/CaptureView.swift */ "Delete all recorded traffic, baselines and alerts?" = "要刪除所有已記錄的流量、基線和警示嗎?"; "Delete everything" = "全部刪除"; -"nettop sampler · the extension didn't answer" = "nettop 取樣器 · 延伸功能沒有回應"; -"Network Extension · unavailable" = "網路延伸功能 · 無法使用"; "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "無法連接過濾器延伸功能。請在「內容過濾器」中啟用,然後按一下「重新整理」。"; "%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — 請在「內容過濾器」中啟用 Flowlight,然後按一下「重新整理」。"; "%@ — the content filter is unavailable" = "%@ — 內容過濾器無法使用"; @@ -667,8 +665,6 @@ "Reconnect to the content filter" = "重新連接內容過濾器"; "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "如果 VPN 或安全代理程式已佔用該位置(Palo Alto Networks GlobalProtect、CrowdStrike Falcon 等都會使用),則停用該過濾器之前,Flowlight 的過濾器無法接收流量。"; "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "過濾器沒有回應,因此 Flowlight 未收到流量。請在「內容過濾器」中啟用,然後按一下「重新整理」。"; -"%@ · receiving" = "%@ · 正在接收"; -"%@ · nothing arriving yet" = "%@ · 還沒有資料傳入"; "Source" = "來源"; "Content filter" = "內容過濾器"; "Observes eligible TCP and UDP flows as they open. Requires the Network Extension entitlement and your approval." = "在符合條件的 TCP 和 UDP 流量建立時就觀察它們。需要網路延伸功能的權利,以及你的允許。"; @@ -725,7 +721,7 @@ "Without this, most traffic shows only an IP address or network owner." = "沒有這項功能,大部分流量只會顯示 IP 位址或網路擁有者。"; "Flowlight reads DNS answers and TLS server names on your primary network interface to name connections." = "Flowlight 會讀取你主要網路介面上的 DNS 回應和 TLS 伺服器名稱,用來為連線命名。"; "Packet contents are never stored or sent anywhere. Only hostnames are kept." = "封包內容一律不會儲存,也不會送到任何地方。只保留主機名稱。"; -"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture." = "macOS 只允許管理者讀取封包,所以這會要求輸入一次密碼。你隨時可以在「擷取」中移除它。"; +"macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector." = "macOS 只允許管理者讀取封包,所以這會要求輸入一次密碼。你隨時可以在「來源 › 收集器」中移除它。"; "Identify network owners when no hostname is available" = "沒有主機名稱時,辨識網路擁有者"; "On by default. Sends public destination IPs to Team Cymru's DNS service. Turn off to keep these lookups on your Mac." = "預設開啟。會把公開的目標 IP 傳送到 Team Cymru 的 DNS 服務。關閉後,這些查詢只會在你的 Mac 上進行。"; "Not Now" = "現在不要"; @@ -756,8 +752,8 @@ /* UI/CoverageView.swift */ "Nothing recorded yet" = "還沒有記錄到任何東西"; +"Nothing recorded in the last hour" = "最近一小時內沒有記錄"; "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "涵蓋範圍是從 Flowlight 已經記錄的流量算出來的。請讓它再執行一會兒。"; -"%@ of traffic fully accounted for" = "%@ 的流量完全有據可查"; "How much of this Mac is covered" = "這台 Mac 涵蓋了多少"; "HTTPS inspection is on" = "HTTPS 檢查已開啟"; "HTTPS inspection is off" = "HTTPS 檢查已關閉"; @@ -805,8 +801,6 @@ "Camera" = "相機"; "Started" = "已啟動"; "Unknown camera" = "未知相機"; -"%@ · nothing active" = "%@ · 沒有活動項目"; -"%@ · %lld active" = "%@ · %lld 個活動項目"; "Watch camera activity" = "監看相機活動"; "Bluetooth, USB and camera activity are off until you turn them on. They need no new permission — they're off because watching them widens what Flowlight looks at, and that should be your decision rather than something an update did.\n\nBluetooth and USB show what is connected. Camera activity shows whether each camera is active, not which app is using it. Flowlight never opens the camera or reads frames." = "藍牙、USB 和相機活動要等你開啟才會作用。它們不需要新的權限 — 預設關閉是因為監看它們會擴大 Flowlight 觀察的範圍,而這應該由你決定,不該是某次更新替你決定的。\n\n藍牙和 USB 會顯示連接的裝置。相機活動會顯示每部相機是否正在使用,但不會顯示是哪個 App 在使用它。Flowlight 絕不會開啟相機或讀取畫面。"; "No payloads or invented attribution. Bluetooth and USB show attachment, while camera monitoring reads only each device's active state. macOS exposes no public camera-owner identity here, so Flowlight names the camera, never guesses the app, and never reads frames." = "不讀取內容,也不捏造歸屬。藍牙和 USB 顯示連接狀態,相機監看只讀取各裝置是否正在使用。macOS 不會在這裡公開使用相機的 App,因此 Flowlight 只標示相機,絕不猜測 App,也不讀取畫面。"; @@ -1058,7 +1052,6 @@ "The network is quiet" = "網路很安靜"; "No app has sent or received data in the last few seconds." = "最近幾秒內沒有 App 傳送或接收資料。"; "Waiting for traffic data" = "正在等待流量資料"; -"Open Capture Settings" = "開啟擷取設定"; /* UI/MockRulesView.swift */ "Answer a chosen endpoint yourself instead of letting the request reach the server — a 500, a rate limit, a malformed body, or a long wait — and watch what the agent does about it." = "由你自己回應選定的端點,而不讓請求送到伺服器 — 一個 500、一次速率限制、一段格式錯誤的內文,或是長時間的等待 — 然後看代理會怎麼處理。"; @@ -1280,8 +1273,8 @@ "Until I resume" = "直到我手動恢復"; "Pause Blocking" = "暫停封鎖"; "Stand every rule down for a while, without deleting anything" = "讓所有規則暫時停止作用,不刪除任何東西"; -"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "有 1 條規則只被監看,沒有實際執行。只有網路延伸功能位在資料路徑上,能夠拒絕連線;nettop 取樣器是事後計數流量的。請在「擷取」中選擇網路延伸功能來實際執行這些規則。"; -"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules." = "有 %lld 條規則只被監看,沒有實際執行。只有網路延伸功能位在資料路徑上,能夠拒絕連線;nettop 取樣器是事後計數流量的。請在「擷取」中選擇網路延伸功能來實際執行這些規則。"; +"One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "有 1 條規則只被監看,沒有實際執行。只有網路延伸功能位在資料路徑上,能夠拒絕連線;nettop 取樣器是事後計數流量的。請在「來源 › 收集器」中選擇網路延伸功能來實際執行這些規則。"; +"%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "有 %lld 條規則只被監看,沒有實際執行。只有網路延伸功能位在資料路徑上,能夠拒絕連線;nettop 取樣器是事後計數流量的。請在「來源 › 收集器」中選擇網路延伸功能來實際執行這些規則。"; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "有 1 條規則只被監看,沒有實際執行 — 指名路徑的規則只有 HTTPS 檢查才比對得到,而它已關閉。"; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "有 %lld 條規則只被監看,沒有實際執行 — 指名路徑的規則只有 HTTPS 檢查才比對得到,而它已關閉。"; "In force" = "生效中"; @@ -1331,7 +1324,7 @@ "Apply a Template" = "套用範本"; "%@ · in force at once" = "%@ · 立即生效"; "%@ · shown first" = "%@ · 優先顯示"; -"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out." = "每個範本都會把一般的規則寫進列表,你可以像對待其他規則一樣閱讀、關閉或刪除它們。拒絕連線是網路延伸功能的工作,所以當「擷取」中選用 nettop 取樣器時,這些規則只會被監看,不會實際執行。"; +"Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out." = "每個範本都會把一般的規則寫進列表,你可以像對待其他規則一樣閱讀、關閉或刪除它們。拒絕連線是網路延伸功能的工作,所以在「來源 › 收集器」中選用 nettop 取樣器時,這些規則只會被監看,不會實際執行。"; "Optional. Left empty this applies to everything on the Mac; naming an app narrows it to that app and the tools it started." = "選填。留空表示套用到這台 Mac 上的所有項目;指名某個 App 則會縮小到該 App 以及它啟動的工具。"; "A domain, an IP address, or a range" = "網域、IP 位址或範圍"; "These name an HTTP method, so only HTTPS inspection can carry them out. With it off they sit in the list watching." = "這些規則指名了 HTTP 方法,所以只有 HTTPS 檢查能執行它們。在它關閉時,這些規則只會留在列表中監看。"; diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift index b7bc6f0d..da133f1c 100644 --- a/Flowlight/Storage/TrafficDatabase.swift +++ b/Flowlight/Storage/TrafficDatabase.swift @@ -2837,7 +2837,8 @@ final class TrafficDatabase: @unchecked Sendable { } /// Share of bytes in the window that carry a hostname, and that have at least an owner. - func coverage(since: Date) throws -> (named: Double, owned: Double) { + /// `hasTraffic` distinguishes an honest 0% from a window with no evidence at all. + func coverage(since: Date) throws -> (named: Double, owned: Double, hasTraffic: Bool) { let row = try conn.query(""" SELECT SUM(bytes_in + bytes_out), SUM(CASE WHEN domain != '' THEN bytes_in + bytes_out ELSE 0 END), @@ -2845,8 +2846,8 @@ final class TrafficDatabase: @unchecked Sendable { FROM agg_1m LEFT JOIN ip_owners o ON o.ip = agg_1m.remote_ip WHERE ts >= ? AND remote_ip != '(unconnected)' """, [.int(Int64(since.timeIntervalSince1970))]) { ($0.double(0), $0.double(1), $0.double(2)) }.first - guard let row, row.0 > 0 else { return (0, 0) } - return (row.1 / row.0, row.2 / row.0) + guard let row, row.0 > 0 else { return (0, 0, false) } + return (row.1 / row.0, row.2 / row.0, true) } // MARK: Alerts diff --git a/Flowlight/UI/AlertsView.swift b/Flowlight/UI/AlertsView.swift index 075d609f..4c6fd742 100644 --- a/Flowlight/UI/AlertsView.swift +++ b/Flowlight/UI/AlertsView.swift @@ -17,26 +17,43 @@ struct AlertsView: View { } var body: some View { - Group { - if visible.isEmpty { - ContentUnavailableView(alerts.isEmpty ? L("No alerts") : L("Nothing to review"), systemImage: "checkmark.shield", - description: Text(alerts.isEmpty - ? L("Each app has a learning period (24 h by default) before first-contact and spike rules fire.") - : L("All matching alerts have been acknowledged."))) - } else { - table + VStack(spacing: 0) { + Picker(L("Alerts"), selection: $nav.alertsTab) { + ForEach(AlertsTab.allCases) { item in Text(item.title).tag(item) } } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 320) + .padding(.vertical, 10) + + Divider() + + Group { + if nav.alertsTab == .detection { + DetectionSettingsView() + } else if visible.isEmpty { + ContentUnavailableView(alerts.isEmpty ? L("No alerts") : L("Nothing to review"), systemImage: "checkmark.shield", + description: Text(alerts.isEmpty + ? L("Each app has a learning period (24 h by default) before first-contact and spike rules fire.") + : L("All matching alerts have been acknowledged."))) + } else { + table + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } .navigationTitle(L("Alerts")) // The sidebar badge counts what is unread; this says how much there is to read at all, which is the // difference between "quiet" and "already dealt with". - .navigationSubtitle(alerts.isEmpty ? "" + .navigationSubtitle(nav.alertsTab == .detection ? L("Detection") + : alerts.isEmpty ? "" : monitor.unacknowledgedAlerts == 0 ? L("%lld alerts, all acknowledged", alerts.count) : L("%lld to review of %lld", monitor.unacknowledgedAlerts, alerts.count)) .toolbar { toolbar } .task(id: monitor.dataVersion) { await load() } .task(id: monitor.unacknowledgedAlerts) { await load() } .task(id: focus.scope) { await load() } + .task(id: nav.alertsTab) { await load() } } private var table: some View { @@ -112,34 +129,36 @@ struct AlertsView: View { private var toolbar: some ToolbarContent { // Two menus rather than four controls of four different shapes. The filter gathers everything that // narrows the list; acknowledging is one verb with two objects, so it reads better as one button. - ToolbarItemGroup { - Menu { - Picker(L("Rule"), selection: $rule) { - Text(L("All rules")).tag("") - ForEach(rules, id: \.self) { Text(AnomalyEngine.Kind.localizedName($0)).tag($0) } + if nav.alertsTab == .alerts { + ToolbarItemGroup { + Menu { + Picker(L("Rule"), selection: $rule) { + Text(L("All rules")).tag("") + ForEach(rules, id: \.self) { Text(AnomalyEngine.Kind.localizedName($0)).tag($0) } + } + .pickerStyle(.inline) + Divider() + Toggle(L("Show acknowledged"), isOn: $showAcknowledged) + } label: { + Label(filterLabel, systemImage: isFiltered ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") } - .pickerStyle(.inline) - Divider() - Toggle(L("Show acknowledged"), isOn: $showAcknowledged) - } label: { - Label(filterLabel, systemImage: isFiltered ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle") - } - .labelStyle(.titleAndIcon) // icon-only reads as two mystery buttons in the corner - .disabled(alerts.isEmpty) - .help(L("Filter which alerts are listed")) + .labelStyle(.titleAndIcon) // icon-only reads as two mystery buttons in the corner + .disabled(alerts.isEmpty) + .help(L("Filter which alerts are listed")) - Menu { - Button(L("Selected (%lld)", selection.count)) { monitor.acknowledgeAlerts(ids: Array(selection)) } - .disabled(selection.isEmpty) - Button(L("All unacknowledged (%lld)", monitor.unacknowledgedAlerts)) { monitor.acknowledgeAlerts(ids: nil) } - .disabled(monitor.unacknowledgedAlerts == 0) - } label: { - Label(L("Acknowledge"), systemImage: "checkmark.circle") + Menu { + Button(L("Selected (%lld)", selection.count)) { monitor.acknowledgeAlerts(ids: Array(selection)) } + .disabled(selection.isEmpty) + Button(L("All unacknowledged (%lld)", monitor.unacknowledgedAlerts)) { monitor.acknowledgeAlerts(ids: nil) } + .disabled(monitor.unacknowledgedAlerts == 0) + } label: { + Label(L("Acknowledge"), systemImage: "checkmark.circle") + } + .labelStyle(.titleAndIcon) + .disabled(alerts.isEmpty) + .help(L("Mark alerts as seen")) } - .labelStyle(.titleAndIcon) - .disabled(alerts.isEmpty) - .help(L("Mark alerts as seen")) } } @@ -166,6 +185,7 @@ struct AlertsView: View { } private func load() async { + guard nav.alertsTab == .alerts else { return } let scope = focus.scope alerts = (try? await monitor.read { try $0.alerts(focus: scope) }) ?? [] } diff --git a/Flowlight/UI/AskView.swift b/Flowlight/UI/AskView.swift index 3bd7f19f..553ce4d4 100644 --- a/Flowlight/UI/AskView.swift +++ b/Flowlight/UI/AskView.swift @@ -76,10 +76,10 @@ private struct AskContent: View { if let filter = call.filter { nav.showReport(filter: filter, end: call.to) } else if let screen = call.screen { - nav.show(screen) + open(screen, sourcesTab: call.sourcesTab) } - } go: { screen in - nav.show(screen) + } go: { call in + if let screen = call.screen { open(screen, sourcesTab: call.sourcesTab) } } .entrance() if turn.id != ask.turns.last?.id { Divider() } @@ -91,6 +91,14 @@ private struct AskContent: View { } } + private func open(_ screen: SidebarItem, sourcesTab: SourcesTab?) { + if screen == .sources { + nav.showSources(sourcesTab ?? .collector) + } else { + nav.show(screen) + } + } + // MARK: Asking private var composer: some View { @@ -192,7 +200,7 @@ private struct AskTurnView: View { let turn: AskTurn let thinking: Bool let open: (AskRecordedCall) -> Void - let go: (SidebarItem) -> Void + let go: (AskRecordedCall) -> Void @State private var showingSent = false @State private var showingCalls = false @@ -223,12 +231,12 @@ private struct AskTurnView: View { if let charted = turn.calls.first(where: { $0.chart != nil }), let chart = charted.chart { AskChartView(chart: chart).entrance() } - if let screen = turn.calls.compactMap(\.screen).first { + if let destination = turn.calls.first(where: { $0.screen != nil }), let screen = destination.screen { // An answer about the app should end somewhere you can act on it — but the app moves when you // click, not when the model decides to. The documentation sits beside it for the longer version, // opened at the section about that same screen. HStack(spacing: 14) { - Button(L("Open %@", screen.title)) { go(screen) } + Button(L("Open %@", screen.title)) { go(destination) } Button(L("Documentation")) { NSWorkspace.shared.open(Help.forScreen(screen)) } } .buttonStyle(.link) diff --git a/Flowlight/UI/CaptureView.swift b/Flowlight/UI/CaptureView.swift index 30775ab3..815dbd01 100644 --- a/Flowlight/UI/CaptureView.swift +++ b/Flowlight/UI/CaptureView.swift @@ -1,5 +1,180 @@ import SwiftUI +/// Pure recovery-state decisions keep the Sources checklist honest and easy to exercise without constructing views. +enum SourceSetupPolicy { + enum CoverageHealth: Equatable { case empty, good, warning } + + static func coverageHealth(named: Double, hasTraffic: Bool) -> CoverageHealth { + guard hasTraffic else { return .empty } + return named > 0.8 ? .good : .warning + } + + static func collectorConfigured(mode: CaptureMode, extensionState: ExtensionManager.State, + extensionFellBack: Bool) -> Bool { + if mode == .nettop { return true } + return extensionState == .enabled && !extensionFellBack + } + + static func setupComplete(mode: CaptureMode, extensionState: ExtensionManager.State, + extensionFellBack: Bool, isReceiving: Bool) -> Bool { + collectorConfigured(mode: mode, extensionState: extensionState, + extensionFellBack: extensionFellBack) && isReceiving + } +} + +/// Collector setup, device channels and coverage belong to one operational destination. The compact health strip +/// answers "is Flowlight working?" before someone has to know which of the three tabs contains the recovery action. +struct SourcesView: View { + @Binding var selection: SourcesTab + @EnvironmentObject var monitor: TrafficMonitor + @EnvironmentObject var extensionManager: ExtensionManager + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 10) { + Picker(L("Sources"), selection: $selection) { + ForEach(SourcesTab.allCases) { tab in Text(tab.title).tag(tab) } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 420) + + HStack(spacing: 10) { + healthCard(.collector, icon: "antenna.radiowaves.left.and.right", + title: L("Collector"), detail: collectorDetail, + tint: monitor.isReceiving ? FL.good : FL.warning) + healthCard(.coverage, icon: "circle.dashed.inset.filled", + title: L("Coverage"), detail: coverageDetail, + tint: coverageTint) + healthCard(.devices, icon: "dot.radiowaves.left.and.right", + title: L("Devices"), detail: deviceDetail, + tint: monitor.devices.isWatchingAnything ? FL.good : .secondary) + } + + if !sourceSetupComplete { sourceRecoveryChecklist } + } + .padding(.horizontal, Measure.gutter) + .padding(.vertical, 12) + .background(.bar) + .overlay(alignment: .bottom) { Divider() } + + Group { + switch selection { + case .collector: CaptureView() + case .devices: DevicesView() + case .coverage: CoverageView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationTitle(L("Sources")) + .navigationSubtitle(selection.title) + } + + private var collectorDetail: String { + if monitor.extensionFellBack { + #if APP_STORE + // The Store product has no sampler fallback; its fail-closed source is unavailable, not nettop. + return L("Nothing is arriving") + #else + return L("nettop sampler") + #endif + } + return monitor.isReceiving ? L("Data is arriving") : L("Nothing is arriving") + } + + private var coverageHealth: SourceSetupPolicy.CoverageHealth { + SourceSetupPolicy.coverageHealth(named: monitor.coverage.named, + hasTraffic: monitor.coverage.hasTraffic) + } + + private var coverageDetail: String { + guard coverageHealth != .empty else { return L("Nothing recorded in the last hour") } + return "\(monitor.coverage.named.formatted(.percent.precision(.fractionLength(0)))) \(L("Named"))" + } + + private var coverageTint: Color { + switch coverageHealth { + case .empty: return .secondary + case .good: return FL.good + case .warning: return FL.warning + } + } + + private var deviceDetail: String { + guard monitor.devices.isWatchingAnything else { return L("Not watching") } + var channels: [String] = [] + if monitor.devices.watchingBluetooth { channels.append(L("Bluetooth")) } + if monitor.devices.watchingUSB { channels.append(L("USB")) } + if monitor.devices.watchingCamera { channels.append(L("Camera")) } + return channels.joined(separator: " · ") + } + + private var collectorConfigured: Bool { + SourceSetupPolicy.collectorConfigured(mode: monitor.mode, extensionState: extensionManager.state, + extensionFellBack: monitor.extensionFellBack) + } + + private var sourceSetupComplete: Bool { + SourceSetupPolicy.setupComplete(mode: monitor.mode, extensionState: extensionManager.state, + extensionFellBack: monitor.extensionFellBack, + isReceiving: monitor.isReceiving) + } + + /// A short recovery path stays beside the source status. It reports completed steps instead of presenting one + /// generic error, and the action opens the one place where every unfinished step can be repaired. + private var sourceRecoveryChecklist: some View { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 7) { + Text(monitor.isReceiving ? L("Collector") : L("Nothing is arriving")) + .font(.caption.weight(.semibold)) + checklistRow(monitor.mode == .networkExtension ? L("Network Extension") : L("nettop sampler"), + complete: collectorConfigured) + checklistRow(L("Data is arriving"), complete: monitor.isReceiving) + } + Spacer(minLength: 8) + Button { selection = .collector } label: { + Label(L("Collector"), systemImage: "chevron.right") + .labelStyle(.titleAndIcon) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(10) + .background(FL.warning.opacity(0.10), in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).stroke(FL.warning.opacity(0.25))) + } + + private func checklistRow(_ title: String, complete: Bool) -> some View { + Label(title, systemImage: complete ? "checkmark.circle.fill" : "circle") + .font(.caption) + .foregroundStyle(complete ? FL.good : .secondary) + } + + private func healthCard(_ tab: SourcesTab, icon: String, title: String, + detail: String, tint: Color) -> some View { + Button { selection = tab } label: { + HStack(alignment: .top, spacing: 9) { + Image(systemName: icon) + .foregroundStyle(tint) + .frame(width: 22, height: 22) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.caption.weight(.semibold)) + Text(detail).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(selection == tab ? FL.accent.opacity(0.10) : Color.secondary.opacity(0.07), + in: RoundedRectangle(cornerRadius: 9)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selection == tab ? .isSelected : []) + } +} + /// Where Flowlight's data comes from, and everything that decides whether it can come from there. /// /// The screen is read top down, in the order the questions are asked: which source is chosen, whether anything is @@ -9,7 +184,6 @@ import SwiftUI struct CaptureView: View { @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var extensionManager: ExtensionManager - @State private var confirmClear = false @State private var showExtensionSetup = false @State private var otherFilters: [InstalledSystemExtension] = [] @@ -35,36 +209,16 @@ struct CaptureView: View { #if !APP_STORE if monitor.mode == .nettop { HostnameSection() } #endif - storageSection } // A settings panel is read, not scanned, and a line of prose stretched across a maximised window is // unreadable. The column stops at a comfortable measure and stays against the sidebar rather than // floating in the middle of the window. .padding(Measure.gutter) } - .navigationTitle(L("Capture")) - // The one thing worth a trip to this screen to find out — what is feeding the app right now — said in the - // title bar, so it can be read from any other screen's neighbour. - .navigationSubtitle(subtitle) #if !APP_STORE // Which other content filters exist decides whether ours can work at all, so it's read when the screen opens. .task { otherFilters = SystemExtensionScan.competingFilters(in: await SystemExtensionScan.read()) } #endif - .confirmationDialog(L("Delete all recorded traffic, baselines and alerts?"), isPresented: $confirmClear) { - Button(L("Delete everything"), role: .destructive) { monitor.clearAllData() } - } - } - - private var subtitle: String { - if monitor.extensionFellBack { - #if APP_STORE - return L("Network Extension · unavailable") - #else - return L("nettop sampler · the extension didn't answer") - #endif - } - let source = monitor.mode == .networkExtension ? L("Network Extension") : L("nettop sampler") - return monitor.isReceiving ? L("%@ · receiving", source) : L("%@ · nothing arriving yet", source) } // MARK: Source @@ -350,33 +504,6 @@ struct CaptureView: View { } .padding(.horizontal, 12).padding(.bottom, 14).padding(.leading, 38) } - - // MARK: Storage - - private var storageSection: some View { - VStack(alignment: .leading, spacing: 8) { - CaptureHeading(L("Storage")) - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 12) { - CaptureGlyph(symbol: "internaldrive", tint: .secondary) - VStack(alignment: .leading, spacing: 3) { - Text(L("Database")).font(.body.weight(.medium)) - Text(monitor.db.url.path) - .font(.caption.monospaced()).foregroundStyle(.secondary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - HStack(spacing: 8) { - Button(L("Reveal in Finder")) { NSWorkspace.shared.activateFileViewerSelecting([monitor.db.url]) } - Spacer(minLength: 8) - Button(L("Clear All Data…"), role: .destructive) { confirmClear = true } - } - .controlSize(.small) - } - .captureCard() - } - } } /// Where hostnames come from in nettop mode, with the one-time packet-capture setup. @@ -551,7 +678,7 @@ struct CaptureOnboardingView: View { VStack(alignment: .leading, spacing: 10) { point("text.magnifyingglass", L("Flowlight reads DNS answers and TLS server names on your primary network interface to name connections.")) point("lock.shield", L("Packet contents are never stored or sent anywhere. Only hostnames are kept.")) - point("key", L("macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Capture.")) + point("key", L("macOS only lets administrators read packets, so this asks for your password once. You can remove it any time in Sources › Collector.")) } Toggle(isOn: $ownerLookup) { diff --git a/Flowlight/UI/ContentView.swift b/Flowlight/UI/ContentView.swift index 7ed9e650..39685c3c 100644 --- a/Flowlight/UI/ContentView.swift +++ b/Flowlight/UI/ContentView.swift @@ -15,6 +15,9 @@ struct ContentView: View { NavigationSplitView { List(selection: Binding(get: { nav.selection }, set: { value in guard let value, value != nav.selection else { return } + // AppKit can replay a restored sidebar row after screenshot launch arguments have selected a screen. + // Pin only deterministic demo captures; ordinary demo sessions remain navigable. + guard !(DemoData.isEnabled && UserDefaults.standard.bool(forKey: "FLPinScreenshotScreen")) else { return } // AppKit reports selection from inside NSTableView's delegate callback. A main-actor Task can begin // before that callback unwinds, so dispatch explicitly to the next run-loop turn before publishing // navigation. AppKit warns about the reentrant reload today and will assert on it in the future. @@ -53,7 +56,7 @@ struct ContentView: View { } #endif FocusBar() - Button { nav.show(.capture) } label: { + Button { nav.showSources(.collector) } label: { HStack(alignment: .top, spacing: 8) { LiveDot(color: monitor.isReceiving ? FL.good : FL.warning, active: monitor.isReceiving) .padding(.top, 4) @@ -89,9 +92,7 @@ struct ContentView: View { InspectView() #endif case .ask: AskView() - case .devices: DevicesView() - case .coverage: CoverageView() - case .capture: CaptureView() + case .sources: SourcesView(selection: $nav.sourcesTab) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Flowlight/UI/CoverageView.swift b/Flowlight/UI/CoverageView.swift index 203657ad..8900c456 100644 --- a/Flowlight/UI/CoverageView.swift +++ b/Flowlight/UI/CoverageView.swift @@ -35,8 +35,6 @@ private struct CoverageContent: View { } .padding(Measure.gutter) } - .navigationTitle(L("Coverage")) - .navigationSubtitle(subtitle) .toolbar { ToolbarItem(placement: .primaryAction) { Picker(L("Window"), selection: $window) { @@ -50,12 +48,6 @@ private struct CoverageContent: View { private struct LoadKey: Equatable { var window: AgentWindow; var version: Int; var inspecting: Bool } - private var subtitle: String { - guard !rows.isEmpty else { return L("Nothing recorded yet") } - return L("%@ of traffic fully accounted for", - CoverageReport.overall(rows).formatted(.percent.precision(.fractionLength(0)))) - } - /// The two engine-wide facts first, because they explain most of the rows underneath and are fixed in one /// place rather than per app. private var summary: some View { diff --git a/Flowlight/UI/DevicesView.swift b/Flowlight/UI/DevicesView.swift index 38574f48..17936168 100644 --- a/Flowlight/UI/DevicesView.swift +++ b/Flowlight/UI/DevicesView.swift @@ -25,25 +25,9 @@ private struct DevicesContent: View { offer } } - .navigationTitle(L("Devices")) - .navigationSubtitle(subtitle) .toolbar { toolbar } } - /// Which channels are on, and how much of it is live. Both channels are off by default, so "nothing here" and - /// "not watching" look the same on the screen — the title bar is where that difference can be stated once. - private var subtitle: String { - var on: [String] = [] - if store.watchingBluetooth { on.append(L("Bluetooth")) } - if store.watchingUSB { on.append(L("USB")) } - if store.watchingCamera { on.append(L("Camera")) } - guard !on.isEmpty else { return L("Not watching") } - let active = store.bluetooth.filter(\.connected).count + store.usb.count - + store.cameras.filter { $0.state == .active }.count - let channels = on.joined(separator: " · ") - return active == 0 ? L("%@ · nothing active", channels) : L("%@ · %lld active", channels, active) - } - @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItemGroup { diff --git a/Flowlight/UI/LiveView.swift b/Flowlight/UI/LiveView.swift index 88fd21d1..edacf629 100644 --- a/Flowlight/UI/LiveView.swift +++ b/Flowlight/UI/LiveView.swift @@ -108,7 +108,7 @@ struct LiveView: View { Text(monitor.status) } } actions: { - Button(L("Open Capture Settings")) { nav.show(.capture) } + Button(L("Sources")) { nav.showSources(.collector) } } } } diff --git a/Flowlight/UI/ReportsView.swift b/Flowlight/UI/ReportsView.swift index 2575ff31..f60f974c 100644 --- a/Flowlight/UI/ReportsView.swift +++ b/Flowlight/UI/ReportsView.swift @@ -420,11 +420,15 @@ struct ReportsView: View { private var coverageNotice: some View { HStack(spacing: 6) { Image(systemName: "info.circle") - Text(L("Hostname coverage, last hour: %@ of bytes", - monitor.coverage.named.formatted(.percent.precision(.fractionLength(0))))) - Text("·") - Text(L("including network owners: %@", - monitor.coverage.owned.formatted(.percent.precision(.fractionLength(0))))) + if monitor.coverage.hasTraffic { + Text(L("Hostname coverage, last hour: %@ of bytes", + monitor.coverage.named.formatted(.percent.precision(.fractionLength(0))))) + Text("·") + Text(L("including network owners: %@", + monitor.coverage.owned.formatted(.percent.precision(.fractionLength(0))))) + } else { + Text(L("Nothing recorded in the last hour")) + } } .font(.caption) .foregroundStyle(.secondary) diff --git a/Flowlight/UI/RulesView.swift b/Flowlight/UI/RulesView.swift index 59a37747..94d6323d 100644 --- a/Flowlight/UI/RulesView.swift +++ b/Flowlight/UI/RulesView.swift @@ -159,8 +159,8 @@ struct RulesView: View { // sentence is a sentence no translator can shape. if monitor.mode != .networkExtension && blind.contains(where: { $0.engine == .flow }) { return blind.count == 1 - ? L("One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules.") - : L("%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Capture to enforce these rules.", blind.count) + ? L("One rule is being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules.") + : L("%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules.", blind.count) } return blind.count == 1 ? L("One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off.") @@ -732,7 +732,7 @@ struct RuleTemplateLibrary: View { private var header: some View { VStack(alignment: .leading, spacing: 6) { Text(L("Templates")).font(.title3.weight(.semibold)) - Text(L("Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Capture these are watched rather than carried out.")) + Text(L("Each one writes ordinary rules into the list, where they can be read, switched off or deleted like anything else. Refusing a connection is the Network Extension's job, so with the nettop sampler selected in Sources › Collector these are watched rather than carried out.")) .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) } .padding(Measure.gutter) diff --git a/Flowlight/UI/SettingsView.swift b/Flowlight/UI/SettingsView.swift index 84daa6dd..d5fda6e2 100644 --- a/Flowlight/UI/SettingsView.swift +++ b/Flowlight/UI/SettingsView.swift @@ -2,25 +2,10 @@ import ServiceManagement import SwiftUI struct SettingsView: View { - @EnvironmentObject var monitor: TrafficMonitor typealias K = AnomalySettings.Keys - @AppStorage(K.sigma) private var sigma = 3.0 - @AppStorage(K.learningHours) private var learningHours = 24.0 - @AppStorage(K.minAlertMB) private var minAlertMB = 1.0 - @AppStorage(K.idleMinutes) private var idleMinutes = 10.0 - @AppStorage(K.idleUploadMB) private var idleUploadMB = 5.0 - @AppStorage(K.firstContact) private var firstContact = true - @AppStorage(K.nonStandardPorts) private var nonStandardPorts = true - @AppStorage(K.ignoreAppleApps) private var ignoreAppleApps = true @AppStorage(K.notifications) private var notifications = true - @AppStorage(K.retentionHours) private var retentionHours = 6.0 @AppStorage(K.menuBarRates) private var menuBarRates = true - @AppStorage(K.agentSensitive) private var agentSensitive = true - @AppStorage(K.agentUnnamed) private var agentUnnamed = true - @AppStorage(K.agentEgressMB) private var agentEgressMB = 100.0 - @AppStorage(K.agentAway) private var agentAway = true - @AppStorage(K.agentAwayMinutes) private var agentAwayMinutes = 15.0 - @AppStorage(AnomalySettings.Keys.backgroundOnly) private var backgroundOnly = false + @AppStorage(K.backgroundOnly) private var backgroundOnly = false // Read once the pane is on screen, never in the property's default expression: that // expression re-runs every time this struct is constructed, and `Settings { }` is // rebuilt whenever the App body is invalidated — which TrafficMonitor does every @@ -28,7 +13,6 @@ struct SettingsView: View { // form put blocking IPC on the main thread at 1 Hz for the life of the process. @State private var launchAtLogin = false @State private var loginItemError: String? - @State private var showClearLocalRiskConfirmation = false var body: some View { TabView { @@ -55,60 +39,8 @@ struct SettingsView: View { .frame(height: 340) .tabItem { Label(L("General"), systemImage: "gearshape") } - Form { - Section { - Stepper(value: $sigma, in: 1.5...10, step: 0.5) { LabeledContent(L("Z-score threshold"), value: "\(sigma.formatted())σ") } - Stepper(value: $learningHours, in: 0...336, step: 6) { LabeledContent(L("Learning period per app"), value: L("%lld h", Int(learningHours))) } - Stepper(value: $minAlertMB, in: 0...1000, step: 1) { LabeledContent(L("Ignore hours below"), value: L("%@ MB", minAlertMB.formatted())) } - } footer: { - Text(L("Hourly traffic and daily destination counts are compared with each app's rolling baseline.")) - .font(.caption).foregroundStyle(.secondary) - } - Section(L("Rules")) { - Toggle(L("First contact with a new domain"), isOn: $firstContact) - Toggle(L("Connections to non-standard ports"), isOn: $nonStandardPorts) - Toggle(L("Ignore Apple's own apps"), isOn: $ignoreAppleApps) - Text(L("macOS talks to Apple constantly — updates, iCloud, push, time — and reporting all of it buries the one line that matters. Their traffic is still recorded, still shown in Live and History, and still subject to every rule and guardrail.")) - .font(.caption).foregroundStyle(.secondary) - } - Section { - Toggle(L("Agent uses email, file transfer, SSH, tunnels or databases"), isOn: $agentSensitive) - Toggle(L("Agent connects to a raw IP on an unusual port"), isOn: $agentUnnamed) - Stepper(value: $agentEgressMB, in: 1...10_000, step: 10) { - LabeledContent(L("Uploads to non-AI hosts above"), value: L("%lld MB/h", Int(agentEgressMB))) - } - Toggle(L("Agent active while you're away"), isOn: $agentAway) - Stepper(value: $agentAwayMinutes, in: 1...240, step: 1) { - LabeledContent(L("Away after"), value: L("%lld min without input", Int(agentAwayMinutes))) - } - .disabled(!agentAway) - } header: { - Text(L("AI agents")) - } footer: { - Text(L("Agents include recognized tools such as Claude Code, Codex and Cursor, plus non-browser apps that contact a known LLM API provider. Their learning period is 1 hour instead of 24 hours.")) - .font(.caption).foregroundStyle(.secondary) - } - Section(L("Traffic without UI activity")) { - Stepper(value: $idleMinutes, in: 1...240, step: 1) { LabeledContent(L("App idle for at least"), value: L("%lld min", Int(idleMinutes))) } - Stepper(value: $idleUploadMB, in: 0.5...1000, step: 0.5) { LabeledContent(L("Uploading more than"), value: L("%@ MB/min", idleUploadMB.formatted())) } - } - LocalRiskSettingsSection(showClearConfirmation: $showClearLocalRiskConfirmation) - } - .formStyle(.grouped) - .frame(height: 640) - .tabItem { Label(L("Detection"), systemImage: "waveform.badge.exclamationmark") } - - Form { - Section { - Stepper(value: $retentionHours, in: 1...72, step: 1) { LabeledContent(L("Keep per-second data"), value: L("%lld h", Int(retentionHours))) } - } footer: { - Text(L("Minute rollups are kept 14 days, hourly 400 days, daily forever. Alerts are kept 90 days.")) - .font(.caption).foregroundStyle(.secondary) - } - } - .formStyle(.grouped) - .frame(height: 150) - .tabItem { Label(L("Storage"), systemImage: "internaldrive") } + DataPrivacySettingsTab() + .tabItem { Label(L("Data & Privacy"), systemImage: "hand.raised.square") } #if !APP_STORE PluginSettingsView() @@ -118,12 +50,7 @@ struct SettingsView: View { ExportSettingsTab() .tabItem { Label(L("Export"), systemImage: "arrow.up.forward.square") } } - .frame(width: 500) - .confirmationDialog(L("Clear local analyses?"), isPresented: $showClearLocalRiskConfirmation, titleVisibility: .visible) { - Button(L("Clear analyses"), role: .destructive) { monitor.clearLocalRiskAnalyses() } - } message: { - Text(L("This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place.")) - } + .frame(width: 540) } private func setLaunchAtLogin(_ enabled: Bool) { @@ -137,6 +64,137 @@ struct SettingsView: View { } } +/// Durable local-data choices stay in Settings; source operation and alert tuning live where their effects appear. +struct DataPrivacySettingsTab: View { + @EnvironmentObject var monitor: TrafficMonitor + @AppStorage(AnomalySettings.Keys.retentionHours) private var retentionHours = 6.0 + @State private var confirmClear = false + + var body: some View { + Form { + Section { + Stepper(value: $retentionHours, in: 1...72, step: 1) { + LabeledContent(L("Keep per-second data"), value: L("%lld h", Int(retentionHours))) + } + } header: { + Text(L("Storage")) + } footer: { + Text(L("Minute rollups are kept 14 days, hourly 400 days, daily forever. Alerts are kept 90 days.")) + .font(.caption).foregroundStyle(.secondary) + } + Section { + LabeledContent(L("Database")) { + Text(monitor.db.url.path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + } + Button(L("Reveal in Finder")) { + NSWorkspace.shared.activateFileViewerSelecting([monitor.db.url]) + } + Button(L("Clear All Data…"), role: .destructive) { confirmClear = true } + } header: { + Text(L("Data & Privacy")) + } + } + .formStyle(.grouped) + .frame(height: 300) + .confirmationDialog(L("Delete all recorded traffic, baselines and alerts?"), isPresented: $confirmClear) { + Button(L("Delete everything"), role: .destructive) { monitor.clearAllData() } + } + // Settings is a separate scene. A database-clear failure must be visible here even when the main window, + // which has its own monitor error alert, is closed or behind this one. + .alert(L("Error"), isPresented: Binding( + get: { monitor.lastError != nil }, + set: { if !$0 { monitor.lastError = nil } } + )) { + Button(L("OK")) { monitor.lastError = nil } + } message: { + Text(monitor.lastError ?? "") + } + } +} + +/// Alert sensitivity is edited beside the alerts it produces instead of in a remote Settings tab. +struct DetectionSettingsView: View { + @EnvironmentObject var monitor: TrafficMonitor + typealias K = AnomalySettings.Keys + @AppStorage(K.sigma) private var sigma = 3.0 + @AppStorage(K.learningHours) private var learningHours = 24.0 + @AppStorage(K.minAlertMB) private var minAlertMB = 1.0 + @AppStorage(K.idleMinutes) private var idleMinutes = 10.0 + @AppStorage(K.idleUploadMB) private var idleUploadMB = 5.0 + @AppStorage(K.firstContact) private var firstContact = true + @AppStorage(K.nonStandardPorts) private var nonStandardPorts = true + @AppStorage(K.ignoreAppleApps) private var ignoreAppleApps = true + @AppStorage(K.agentSensitive) private var agentSensitive = true + @AppStorage(K.agentUnnamed) private var agentUnnamed = true + @AppStorage(K.agentEgressMB) private var agentEgressMB = 100.0 + @AppStorage(K.agentAway) private var agentAway = true + @AppStorage(K.agentAwayMinutes) private var agentAwayMinutes = 15.0 + @State private var showClearLocalRiskConfirmation = false + + var body: some View { + Form { + Section { + Stepper(value: $sigma, in: 1.5...10, step: 0.5) { + LabeledContent(L("Z-score threshold"), value: "\(sigma.formatted())σ") + } + Stepper(value: $learningHours, in: 0...336, step: 6) { + LabeledContent(L("Learning period per app"), value: L("%lld h", Int(learningHours))) + } + Stepper(value: $minAlertMB, in: 0...1000, step: 1) { + LabeledContent(L("Ignore hours below"), value: L("%@ MB", minAlertMB.formatted())) + } + } footer: { + Text(L("Hourly traffic and daily destination counts are compared with each app's rolling baseline.")) + .font(.caption).foregroundStyle(.secondary) + } + Section(L("Rules")) { + Toggle(L("First contact with a new domain"), isOn: $firstContact) + Toggle(L("Connections to non-standard ports"), isOn: $nonStandardPorts) + Toggle(L("Ignore Apple's own apps"), isOn: $ignoreAppleApps) + Text(L("macOS talks to Apple constantly — updates, iCloud, push, time — and reporting all of it buries the one line that matters. Their traffic is still recorded, still shown in Live and History, and still subject to every rule and guardrail.")) + .font(.caption).foregroundStyle(.secondary) + } + Section { + Toggle(L("Agent uses email, file transfer, SSH, tunnels or databases"), isOn: $agentSensitive) + Toggle(L("Agent connects to a raw IP on an unusual port"), isOn: $agentUnnamed) + Stepper(value: $agentEgressMB, in: 1...10_000, step: 10) { + LabeledContent(L("Uploads to non-AI hosts above"), value: L("%lld MB/h", Int(agentEgressMB))) + } + Toggle(L("Agent active while you're away"), isOn: $agentAway) + Stepper(value: $agentAwayMinutes, in: 1...240, step: 1) { + LabeledContent(L("Away after"), value: L("%lld min without input", Int(agentAwayMinutes))) + } + .disabled(!agentAway) + } header: { + Text(L("AI agents")) + } footer: { + Text(L("Agents include recognized tools such as Claude Code, Codex and Cursor, plus non-browser apps that contact a known LLM API provider. Their learning period is 1 hour instead of 24 hours.")) + .font(.caption).foregroundStyle(.secondary) + } + Section(L("Traffic without UI activity")) { + Stepper(value: $idleMinutes, in: 1...240, step: 1) { + LabeledContent(L("App idle for at least"), value: L("%lld min", Int(idleMinutes))) + } + Stepper(value: $idleUploadMB, in: 0.5...1000, step: 0.5) { + LabeledContent(L("Uploading more than"), value: L("%@ MB/min", idleUploadMB.formatted())) + } + } + LocalRiskSettingsSection(showClearConfirmation: $showClearLocalRiskConfirmation) + } + .formStyle(.grouped) + .confirmationDialog(L("Clear local analyses?"), isPresented: $showClearLocalRiskConfirmation, + titleVisibility: .visible) { + Button(L("Clear analyses"), role: .destructive) { monitor.clearLocalRiskAnalyses() } + } message: { + Text(L("This removes only local potential-harm scores and explanations. Recorded requests, response bodies, alerts, rules, and inspection settings stay in place.")) + } + } +} + private struct NotificationSettingsRow: View { @Binding var enabled: Bool @State private var authorization: NotificationAuthorizationState = .unknown @@ -185,7 +243,7 @@ struct MenuBarContent: View { var body: some View { if extensionManager.state.showsMenuBarNotice { Button { - nav.show(.capture) + nav.showSources(.collector) openWindow(id: "main") NSApp.activate() } label: { @@ -222,7 +280,7 @@ struct MenuBarContent: View { } if monitor.unacknowledgedAlerts > 0 { Button(L("Review Alerts…")) { - nav.show(.alerts) + nav.showAlerts() openWindow(id: "main") NSApp.activate() } diff --git a/FlowlightTests/AskFeatureTests.swift b/FlowlightTests/AskFeatureTests.swift index 4d4c0f15..968d24dd 100644 --- a/FlowlightTests/AskFeatureTests.swift +++ b/FlowlightTests/AskFeatureTests.swift @@ -82,7 +82,8 @@ final class AskSettingsQueryTests: XCTestCase { XCTAssertEqual(json["httpsInspection"] as? String, "off") XCTAssertEqual(json["canRefuseConnections"] as? String, "yes") XCTAssertEqual(json["watchingCameraActivity"] as? String, "yes") - XCTAssertEqual(result.screen, .capture) + XCTAssertEqual(result.screen, .sources) + XCTAssertEqual(result.sourcesTab, .collector) } func testSettingsCarryNoSecrets() throws { @@ -100,6 +101,20 @@ final class AskSettingsQueryTests: XCTestCase { XCTAssertEqual(result.screen, .inspect, "the answer should be able to offer to take you there") } + func testHowToSourcesSelectTheExactOperationalTab() throws { + let collector = try AskQueryRunner.run( + AskCall(query: .howTo, arguments: ["topic": "capture source"]), db: db, environment: environment + ) + XCTAssertEqual(collector.screen, .sources) + XCTAssertEqual(collector.sourcesTab, .collector) + + let devices = try AskQueryRunner.run( + AskCall(query: .howTo, arguments: ["topic": "camera and bluetooth"]), db: db, environment: environment + ) + XCTAssertEqual(devices.screen, .sources) + XCTAssertEqual(devices.sourcesTab, .devices) + } + func testHowToWithNoMatchListsWhatThereIsInsteadOfGuessing() throws { let result = try AskQueryRunner.run(AskCall(query: .howTo, arguments: ["topic": "zzzzqqqq"]), db: db, environment: environment) diff --git a/FlowlightTests/AskTests.swift b/FlowlightTests/AskTests.swift index 1c63c574..8b4aa799 100644 --- a/FlowlightTests/AskTests.swift +++ b/FlowlightTests/AskTests.swift @@ -257,7 +257,8 @@ final class AskTests: XCTestCase { XCTAssertEqual(rows.first?["device"] as? String, "Desk Camera") XCTAssertEqual(rows.first?["started"] as? Int, 1) XCTAssertEqual(rows.first?["stopped"] as? Int, 1) - XCTAssertEqual(result.screen, .devices) + XCTAssertEqual(result.screen, .sources) + XCTAssertEqual(result.sourcesTab, .devices) XCTAssertFalse(result.json.contains("private-camera-uid"), "stable hardware identifiers never leave the Mac") XCTAssertFalse(result.json.contains("private-usb-serial")) } diff --git a/FlowlightTests/VisualizeNavigationTests.swift b/FlowlightTests/VisualizeNavigationTests.swift index 00cb26f1..ff99d071 100644 --- a/FlowlightTests/VisualizeNavigationTests.swift +++ b/FlowlightTests/VisualizeNavigationTests.swift @@ -35,14 +35,39 @@ final class VisualizeNavigationTests: XCTestCase { XCTAssertEqual(SidebarSection.traffic.items, [.live, .agents, .reports, .visualize, .alerts]) XCTAssertEqual(SidebarItem.sidebarOrder, [.live, .agents, .reports, .visualize, .alerts, - .ask, .inspect, .rules, .devices, .coverage, .capture]) + .ask, .inspect, .rules, .sources]) XCTAssertEqual(SidebarItem.visualize.shortcut, "4") XCTAssertEqual(SidebarItem.alerts.shortcut, "5") XCTAssertEqual(SidebarItem.rules.shortcut, "8") - XCTAssertEqual(SidebarItem.coverage.shortcut, "0") - XCTAssertNil(SidebarItem.capture.shortcut, - "the eleventh destination remains reachable without inventing a shortcut") - XCTAssertEqual(SidebarItem.sidebarOrder.filter { $0.shortcut == nil }, [.capture]) + XCTAssertEqual(SidebarItem.sources.shortcut, "9") + XCTAssertTrue(SidebarItem.sidebarOrder.allSatisfy { $0.shortcut != nil }) + } + + func testSourcesCoverageDistinguishesNoEvidenceFromZeroPercentNamed() { + XCTAssertEqual(SourceSetupPolicy.coverageHealth(named: 0, hasTraffic: false), .empty) + XCTAssertEqual(SourceSetupPolicy.coverageHealth(named: 0, hasTraffic: true), .warning) + XCTAssertEqual(SourceSetupPolicy.coverageHealth(named: 0.8, hasTraffic: true), .warning) + XCTAssertEqual(SourceSetupPolicy.coverageHealth(named: 0.81, hasTraffic: true), .good) + } + + func testSourcesRecoveryChecklistRequiresAConfiguredCollectorAndArrivingData() { + XCTAssertTrue(SourceSetupPolicy.collectorConfigured(mode: .nettop, extensionState: .notInstalled, + extensionFellBack: false), + "the sampler needs no separate filter setup") + XCTAssertFalse(SourceSetupPolicy.collectorConfigured(mode: .networkExtension, + extensionState: .notInstalled, + extensionFellBack: false)) + XCTAssertTrue(SourceSetupPolicy.collectorConfigured(mode: .networkExtension, + extensionState: .enabled, + extensionFellBack: false)) + XCTAssertFalse(SourceSetupPolicy.collectorConfigured(mode: .networkExtension, + extensionState: .enabled, + extensionFellBack: true), + "fallback is working but is not the configured collector") + XCTAssertFalse(SourceSetupPolicy.setupComplete(mode: .nettop, extensionState: .unknown, + extensionFellBack: false, isReceiving: false)) + XCTAssertTrue(SourceSetupPolicy.setupComplete(mode: .nettop, extensionState: .unknown, + extensionFellBack: false, isReceiving: true)) } func testFocusNoticeClaimsFilteringOnlyWhereTheWholeScopeApplies() { @@ -60,9 +85,7 @@ final class VisualizeNavigationTests: XCTestCase { XCTAssertFalse(SidebarItem.alerts.fullyAppliesFocus(mixed)) XCTAssertFalse(SidebarItem.ask.fullyAppliesFocus(mixed)) XCTAssertFalse(SidebarItem.rules.fullyAppliesFocus(mixed)) - XCTAssertFalse(SidebarItem.devices.fullyAppliesFocus(mixed)) - XCTAssertFalse(SidebarItem.coverage.fullyAppliesFocus(mixed)) - XCTAssertFalse(SidebarItem.capture.fullyAppliesFocus(mixed)) + XCTAssertFalse(SidebarItem.sources.fullyAppliesFocus(mixed)) } func testConnectionsKeepsTheCompatibleVisualizeRouteAndHelpAnchor() { @@ -154,6 +177,17 @@ final class VisualizeNavigationTests: XCTestCase { ) } + func testReviewAlertsNavigationReturnsToTheAlertList() { + let nav = AppNavigation() + nav.show(.alerts) + nav.alertsTab = .detection + + nav.showAlerts() + + XCTAssertEqual(nav.selection, .alerts) + XCTAssertEqual(nav.alertsTab, .alerts) + } + func testExactInspectNavigationCarriesExactMemberScope() { let nav = AppNavigation() VisualizeActionScope.showInspect(nav, filter: TrafficFilter( diff --git a/README.md b/README.md index 95881298..8aa6a92b 100644 --- a/README.md +++ b/README.md @@ -220,8 +220,10 @@ there is still data. It says which rung it is on and how long until the next one rewritten, blocking is switched off while the sampler stands in (the sampler can't refuse anything, and Rules shouldn't claim otherwise), and **Try the extension again** starts over. -### Devices: channels that aren't the network -A Mac sends and receives over more than TCP and UDP. The **Devices** screen covers the rest, with every channel off +### Sources: collector, coverage and devices +**Sources** keeps collector health and recovery, hostname coverage, and opt-in device channels in one destination. Its health summary shows whether traffic is arriving, how much is named, and which device channels are active. + +A Mac sends and receives over more than TCP and UDP. **Sources › Devices** covers the non-network channels, with every channel off until you turn it on because watching more of your Mac should be a decision rather than something an update did. **Camera activity**: whether each camera is active and when it started or stopped. Flowlight reads the public @@ -277,7 +279,7 @@ talks to a lot of places for good reasons. Flowlight keeps recording with its window closed and costs almost nothing while idle. **Settings › General › Run in the background** drops the Dock icon and the Cmd-Tab entry; the menu bar keeps the rates and offers **Show Dock Icon** to undo it. Quitting stops the sampler — it does not stop the Network Extension, which runs on its own until -you disable it in Capture. +you disable it in Sources › Collector. ### Running in the background, quietly The window reopens where and how you left it; a first run fills the screen. Hostname setup is offered a little after @@ -314,7 +316,7 @@ appears in Live and History like any other app's traffic, including a collector No account, no cloud, no telemetry. Everything stays in a local SQLite database. Packet capture reads only DNS answers and TLS ClientHellos (a kernel filter drops everything else), and **packet contents are never stored**. Naming who owns an IP with no known hostname is **on by default**: it sends those public IPs (never private ones) to -Team Cymru's DNS service, and you can turn it off in Capture. Release and official rule-pack checks make standard HTTPS +Team Cymru's DNS service, and you can turn it off in Sources › Collector. Release and official rule-pack checks make standard HTTPS requests with no traffic or pack settings; each has its own off switch in Settings or Rules › Packs. To attribute tools and MCP servers, Flowlight reads process command lines and your agents' MCP config files on your Mac; none of it leaves your Mac. HTTPS inspection is off unless you turn it on, and what it records stays in the @@ -354,7 +356,7 @@ same local database. asks to quit and install it (Flowlight › Check for Updates…). Updating by hand? Quit Flowlight before dragging the new version in. 3. On first launch, **Name Your Traffic** offers the one-time setup that lets Flowlight read hostnames (it asks - for your password once). Network-owner lookups are on by default and can be turned off there or in Capture. + for your password once). Network-owner lookups are on by default and can be turned off there or in Sources › Collector. > **Try it without your own data:** `open /Applications/Flowlight.app --args -FLDemo YES` launches with 90 days > of synthetic traffic, including an inspected Claude Code session. That's what the screenshots show. @@ -438,7 +440,7 @@ The [threat model](https://flowlight.xinbetween.com/threat-model/) says what is to confirm the filter. Flowlight works without it on the nettop sampler. - **Only one content filter runs at a time on macOS.** On a Mac where security software already holds that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar — Flowlight's filter installs and connects but is never handed - any traffic. Capture lists the filters it finds and offers the sampler instead, which needs no filter. + any traffic. Sources › Collector lists the filters it finds and offers the sampler instead, which needs no filter. ## Roadmap @@ -551,7 +553,7 @@ Flowlight/ Storage/ SQLite store, rollups, queries Analysis/ anomaly engine, AI agent catalog + rules Inspection/ opt-in HTTPS inspection: local CA, proxy, HTTP parser, tool-call reader - UI/ Live · AI Agents · History · Alerts · Inspect · Connections · Capture + UI/ Live · AI Agents · History · Alerts · Inspect · Connections · Sources FlowlightTests/ 105 unit tests: parsers, BPF filter, rollups, charts, agents, MCP, allowlists, inspection, updates docs/ website (GitHub Pages) and developer guide ``` diff --git a/docs/404.html b/docs/404.html index 8c4e1fed..0d6bebcd 100644 --- a/docs/404.html +++ b/docs/404.html @@ -86,7 +86,7 @@

That page isn't here

Try the home page,

diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e3878414..7d39f944 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -27,7 +27,7 @@ Flowlight/ SwiftUI host app Analysis/ EWMA/z-score baselines, rule engine, AI agent catalog + rules, UI-activity tracker Inspection/ HTTPS inspection: local CA, decrypting proxy, HTTP parser, recorder, mock rules Export/ opt-in export to a collector the user chooses: OTLP/NDJSON payloads, queue, Keychain - UI/ Live, AI Agents, History (charts + three breakdown groupings + CSV), Alerts, Connections, Capture + UI/ Live, AI Agents, History (charts + three breakdown groupings + CSV), Alerts, Connections, Sources Channels/ Opt-in Bluetooth, USB/storage and CoreMediaIO camera-activity monitors Shared/Classification/ProtocolCatalog.swift 108 protocols in 13 families, used by both capture engines FlowlightTests/ Parser, classifier, BPF filter, rollup, chart, agent and anomaly tests @@ -43,16 +43,16 @@ FlowlightTests/ Parser, classifier, BPF filter, rollup, chart, agent | Protocols | First bytes + ports | Ports only | | Short-lived flows | Every flow | Flows shorter than ~1 s are missed | -Choose the source in **Capture**. Both produce the same per-second batches, so storage, reports and +Choose the source in **Sources › Collector**. Both produce the same per-second batches, so storage, reports and anomaly detection work the same way with either one. ### Hostnames in nettop mode nettop only reports IP addresses, so Flowlight fills in names from several sources, in order: -1. **Packet capture** (Capture › Hostnames › *Enable Packet Capture…*). A one-time admin prompt installs +1. **Packet capture** (Sources › Collector › Hostnames › *Enable Packet Capture…*). A one-time admin prompt installs `/Library/LaunchDaemons/com.flowlight.bpf-access.plist`, which gives the `access_bpf` group read access to - `/dev/bpf*` at boot. This is the same approach as Wireshark's ChmodBPF. Capture follows the primary interface, + `/dev/bpf*` at boot. This is the same approach as Wireshark's ChmodBPF. The collector follows the primary interface, so traffic confined to another interface or tunnel may lack names. A kernel BPF filter passes only DNS responses and TLS ClientHellos, so the cost doesn't grow with traffic. Packet contents are never stored. *Remove Packet Capture Access…* undoes the setup. On first launch, Flowlight offers the setup once @@ -60,7 +60,7 @@ nettop only reports IP addresses, so Flowlight fills in names from several sourc granting BPF access. Launch with `-FLForceCaptureOnboarding YES` to show the offer again. 2. **Reverse DNS**, as a last resort. 3. **Network owner**: the IP's autonomous system (e.g. "Cloudflare, Inc. · AS13335"), from Team Cymru's DNS - interface. This lookup is on by default and sends only public IPs; turn it off during setup or in Capture. + interface. This lookup is on by default and sends only public IPs; turn it off during setup or in Sources › Collector. Public IPs are sent to that service, while local and reserved addresses stay on the Mac. History groups any traffic still without a hostname under its network owner. @@ -100,7 +100,7 @@ without a test host, so it is the quicker way to check that particular thing any (https://developer.apple.com/contact/request/network-extension). A free Personal Team can't sign it. 2. For development, before the entitlement is granted: disable SIP on a test Mac and run `systemextensionsctl developer on`. -3. Copy the app to `/Applications`, open **Capture**, and click **Install & enable filter**. +3. Copy the app to `/Applications`, open **Sources › Collector**, and click **Install & enable filter**. Then approve it in System Settings › General › Login Items & Extensions, and allow the content filter. 4. Switch the source to **Network Extension**. 5. Check the extension's logs: @@ -110,7 +110,7 @@ The App Group and Mach service names come from `$(TeamIdentifierPrefix)com.flowl ## Device activity -The **Devices** screen is opt-in per channel. Bluetooth and USB/storage report attachment, while camera monitoring +**Sources › Devices** is opt-in per channel. Bluetooth and USB/storage report attachment, while camera monitoring uses public CoreMediaIO device metadata only: `kCMIOHardwarePropertyDevices` for enumeration and `kCMIODevicePropertyDeviceIsRunningSomewhere` for active state. A property listener reports transitions without opening an `AVCaptureSession`, requesting TCC capture access, or reading frames. The public signal is per camera and diff --git a/docs/about/index.html b/docs/about/index.html index 07f34f9b..5ede6eb9 100644 --- a/docs/about/index.html +++ b/docs/about/index.html @@ -153,7 +153,7 @@

Thanks

diff --git a/docs/assets/screenshots/agent-detail.png b/docs/assets/screenshots/agent-detail.png index a66c235af389f3fe43dc4ae22831fdb6c63b8d23..c6a37dd6b7c874574b6fa0aaa44392710c305357 100644 GIT binary patch literal 654466 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