diff --git a/.github/workflows/nightly-product-eval.yml b/.github/workflows/nightly-product-eval.yml new file mode 100644 index 00000000..703fcde8 --- /dev/null +++ b/.github/workflows/nightly-product-eval.yml @@ -0,0 +1,44 @@ +name: Nightly Product Evaluation + +on: + schedule: + - cron: "0 9 * * *" + workflow_dispatch: + inputs: + mode: + description: "Select dry-run for a no-side-effects preview or run for the full evaluation." + required: false + default: "run" + type: choice + options: + - run + - dry-run + +jobs: + eval: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: | + npm install + + - name: Run nightly product evaluation + run: npm run nightly-eval:${{ github.event.inputs.mode || 'run' }} + + - name: Upload evaluation artifacts + uses: actions/upload-artifact@v4 + with: + name: nightly-product-eval + path: | + docs/notes/eval-logs/nightly-product-eval.log + docs/notes/eval-logs/nightly-product-eval-server.log + docs/notes/eval-logs/nightly-product-eval.json + docs/notes/eval-logs/nightly-product-eval-socketio-smoke.log diff --git a/.gitignore b/.gitignore index 9597e7c8..db64f9ff 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ config.json history.json .google_drive/ +/.claude/ reports_archive/ phase2_results.xml diff --git a/.tmp-clang-module-cache/296KTZ3YDJN7L/SwiftShims-OSFJ5GNFR44U.pcm b/.tmp-clang-module-cache/296KTZ3YDJN7L/SwiftShims-OSFJ5GNFR44U.pcm new file mode 100644 index 00000000..dd8c0790 Binary files /dev/null and b/.tmp-clang-module-cache/296KTZ3YDJN7L/SwiftShims-OSFJ5GNFR44U.pcm differ diff --git a/.tmp-clang-module-cache/296KTZ3YDJN7L/_SwiftConcurrencyShims-OSFJ5GNFR44U.pcm b/.tmp-clang-module-cache/296KTZ3YDJN7L/_SwiftConcurrencyShims-OSFJ5GNFR44U.pcm new file mode 100644 index 00000000..d432745d Binary files /dev/null and b/.tmp-clang-module-cache/296KTZ3YDJN7L/_SwiftConcurrencyShims-OSFJ5GNFR44U.pcm differ diff --git a/.tmp-clang-module-cache/M8SZ5HY67AJE/SwiftShims-OSFJ5GNFR44U.pcm b/.tmp-clang-module-cache/M8SZ5HY67AJE/SwiftShims-OSFJ5GNFR44U.pcm new file mode 100644 index 00000000..e8f1609a Binary files /dev/null and b/.tmp-clang-module-cache/M8SZ5HY67AJE/SwiftShims-OSFJ5GNFR44U.pcm differ diff --git a/.tmp-clang-module-cache/M8SZ5HY67AJE/_SwiftConcurrencyShims-OSFJ5GNFR44U.pcm b/.tmp-clang-module-cache/M8SZ5HY67AJE/_SwiftConcurrencyShims-OSFJ5GNFR44U.pcm new file mode 100644 index 00000000..ae275a92 Binary files /dev/null and b/.tmp-clang-module-cache/M8SZ5HY67AJE/_SwiftConcurrencyShims-OSFJ5GNFR44U.pcm differ diff --git a/.tmp-clang-module-cache/PackageDescription-2AARQH6799GTG.swiftmodule b/.tmp-clang-module-cache/PackageDescription-2AARQH6799GTG.swiftmodule new file mode 100644 index 00000000..67745cad Binary files /dev/null and b/.tmp-clang-module-cache/PackageDescription-2AARQH6799GTG.swiftmodule differ diff --git a/.tmp-clang-module-cache/Swift-4C7SDBTP6RVE.swiftmodule b/.tmp-clang-module-cache/Swift-4C7SDBTP6RVE.swiftmodule new file mode 100644 index 00000000..f9b066f9 --- /dev/null +++ b/.tmp-clang-module-cache/Swift-4C7SDBTP6RVE.swiftmodule @@ -0,0 +1,12 @@ +--- +path: '/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/27.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule' +dependencies: + - mtime: 1780674774000000000 + path: '/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/27.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule' + size: 15109180 + - mtime: 1779515397000000000 + path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface' + size: 2358258 + sdk_relative: true +version: 1 +... diff --git a/.tmp-clang-module-cache/SwiftOnoneSupport-IAEY8FV1SFKR.swiftmodule b/.tmp-clang-module-cache/SwiftOnoneSupport-IAEY8FV1SFKR.swiftmodule new file mode 100644 index 00000000..d4891b8c --- /dev/null +++ b/.tmp-clang-module-cache/SwiftOnoneSupport-IAEY8FV1SFKR.swiftmodule @@ -0,0 +1,16 @@ +--- +path: '/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/27.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule' +dependencies: + - mtime: 1780674778000000000 + path: '/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/27.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule' + size: 18852 + - mtime: 1779515397000000000 + path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface' + size: 2358258 + sdk_relative: true + - mtime: 1779515917000000000 + path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface' + size: 1397 + sdk_relative: true +version: 1 +... diff --git a/.tmp-clang-module-cache/_Concurrency-8Y51X8E13BP6.swiftmodule b/.tmp-clang-module-cache/_Concurrency-8Y51X8E13BP6.swiftmodule new file mode 100644 index 00000000..ba7cb523 --- /dev/null +++ b/.tmp-clang-module-cache/_Concurrency-8Y51X8E13BP6.swiftmodule @@ -0,0 +1,16 @@ +--- +path: '/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/27.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule' +dependencies: + - mtime: 1780674787000000000 + path: '/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/27.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule' + size: 772472 + - mtime: 1779515397000000000 + path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface' + size: 2358258 + sdk_relative: true + - mtime: 1779515939000000000 + path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface' + size: 294592 + sdk_relative: true +version: 1 +... diff --git a/.tmp-clang-module-cache/_StringProcessing-35JWMHVX1K6XG.swiftmodule b/.tmp-clang-module-cache/_StringProcessing-35JWMHVX1K6XG.swiftmodule new file mode 100644 index 00000000..dada8568 --- /dev/null +++ b/.tmp-clang-module-cache/_StringProcessing-35JWMHVX1K6XG.swiftmodule @@ -0,0 +1,16 @@ +--- +path: '/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/27.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule' +dependencies: + - mtime: 1780674781000000000 + path: '/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/27.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule' + size: 84496 + - mtime: 1779515397000000000 + path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface' + size: 2358258 + sdk_relative: true + - mtime: 1779518461000000000 + path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface' + size: 23721 + sdk_relative: true +version: 1 +... diff --git a/.tmp-clang-module-cache/modules.timestamp b/.tmp-clang-module-cache/modules.timestamp new file mode 100644 index 00000000..e69de29b diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 672f8f08..00000000 --- a/Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -FROM node:20-bookworm-slim - -ENV NODE_ENV=production \ - NMAPUI_DATA_DIR=/data \ - PORT=9000 - -WORKDIR /app - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - chromium \ - curl \ - lsof \ - net-tools \ - nmap \ - python3 \ - traceroute \ - wkhtmltopdf \ - xsltproc \ - && rm -rf /var/lib/apt/lists/* - -COPY package*.json /app/ -RUN npm ci --omit=dev - -COPY . /app - -RUN mkdir -p /data/reports_archive /data/work \ - && useradd --system --create-home --home-dir /home/nmapui nmapui \ - && chown -R nmapui:nmapui /app /data - -USER nmapui - -EXPOSE 9000 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD curl -fsS http://127.0.0.1:9000/api/app-identity >/dev/null || exit 1 - -CMD ["node", "server.js"] diff --git a/README.md b/README.md index 50f14dc5..840f14b3 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,21 @@ macOS-first network scanning and monitoring app powered by Nmap. git clone https://github.com/techmore/TM-NmapUI.git cd TM-NmapUI ./install.sh -sudo npm start +cd packaging/macos && ./run.sh ``` The app will open its local UI automatically. If you need to access it directly, use the loopback URL shown by the launcher, usually `http://127.0.0.1:9000`. -`npm start` also checks for missing Node packages and installs them automatically, so a clean checkout will recover if `node_modules/` has not been created yet. +The Swift-native launcher handles the app startup path directly. ## Tech Stack | Layer | Technology | |-------|------------| -| Runtime | Node.js | +| Runtime | Swift menu bar app | | Desktop Shell | Swift menu bar app | -| Web Framework | Express | -| Real-time | Socket.IO | +| Web Framework | Legacy Node host only | +| Real-time | Native Swift transport | | Scanner | Nmap + NSE (Nmap Scripting Engine) | | PDF Generation | wkhtmltopdf / Chromium | | XML Processing | xml2js | @@ -34,7 +34,6 @@ The app will open its local UI automatically. If you need to access it directly, - **macOS** (primary target) - **Homebrew** (for package management) -- **Node.js** (via Homebrew) - **Nmap** (with script database updated) - **wkhtmltopdf** or **Chromium** (for PDF generation) - **xsltproc** (for HTML report styling) @@ -59,31 +58,9 @@ All dependencies are installed automatically by `install.sh`. > **Prerequisites**: Xcode Command Line Tools (`xcode-select --install`) and [Homebrew](https://brew.sh) are needed by `install.sh` to pull in `nmap`, `arp-scan`, etc. -## Container Build - -If you want a more atomic install path, the repository now includes a container build that packages the Node runtime with its scan/report tooling. - -Build the image: -```bash -docker build -t nmapui:container . -``` - -Run it with Docker Compose: -```bash -docker compose up --build -``` - -The container listens on `0.0.0.0:9000` and persists runtime data in `/data`. The app now reads mutable files from that directory, so scan history, settings, reports, and temporary scan artifacts survive restarts. If you want to override defaults locally, set `NMAPUI_DATA_DIR`, `NMAPUI_PORT`, or `PORT` in your shell or a compose `.env` file. - -Notes: -- The container includes `nmap`, `xsltproc`, `wkhtmltopdf`, Chromium, `traceroute`, and `python3` for the helper scripts. -- Network scanning still depends on the runtime’s network permissions. The bundled compose file adds `NET_ADMIN` and `NET_RAW`, which are typically required for fuller scan capabilities. -- For environments like Apple Container Machines, the same image can be used as a starting point, but you may need to adjust network exposure or host access based on the target runtime’s policies. -- The container healthcheck hits `/api/app-identity`, which is a lightweight readiness signal for orchestration. - ## Repository Layout -- Root: stable entrypoints and runtime files such as `server.js`, `install.sh`, and `deploy.sh` +- Root: stable entrypoints and runtime files such as `install.sh`, `deploy.sh`, and repository-level scripts - `NmapUI.app/` and `NmapUIMenuBar.app/`: macOS app bundles produced by the current packaging flow - `packaging/macos/`: Swift/AppKit shell scaffold for the macOS-native direction - `docs/guides/`: user and maintainer guides @@ -106,17 +83,38 @@ If you are migrating an existing runtime database into the menu bar app bundle, cp /path/to/runtime.sqlite3 NmapUIMenuBar.app/Contents/Resources/data/runtime.sqlite3 ``` -The current repository does not include the old `build.sh` installer flow referenced in earlier notes. +The current repository does not include the old installer flow referenced in earlier notes. ## Usage ### Start the app ```bash -sudo npm start +cd packaging/macos && ./run.sh ``` -The launcher starts the local runtime on port 9000 by default and opens the app shell around it. On the macOS wrapper, the menu bar icon is the primary way back into the app. +The launcher starts the native macOS shell and opens the loopback UI on port 9000 by default. On the macOS wrapper, the menu bar icon is the primary way back into the app. + +### Nightly Eval + +```bash +./scripts/nightly_product_eval.sh --run +``` + +This runs the nightly product evaluation loop, which boots the Swift-native app, checks key runtime endpoints and assets, and records an evaluation artifact under `docs/notes/eval-logs/`. + +For a no-side-effects preview of the loop shape, run: + +```bash +./scripts/nightly_product_eval.sh --dry-run +``` + +To install or remove the macOS scheduler from the repo root, use: + +```bash +./packaging/macos/nightly_product_eval_launchd.sh install +./packaging/macos/nightly_product_eval_launchd.sh uninstall +``` ### Scans @@ -140,10 +138,10 @@ HTML and PDF reports are generated after each scan. Reports include: ``` . -├── server.js # Main local runtime +├── packaging/macos/ # Swift-native macOS app shell ├── install.sh # Dependency installer -├── package.json # Node dependencies -├── google_drive.py # Google Drive sync helper +├── package.json # Legacy Node tooling and helper scripts +├── google_drive_bridge.js # Legacy Google Drive helper dispatch layer ├── nmap-modern.xsl # Report stylesheet ├── config.json # App configuration ├── history.json # Scan history diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 7c84b2b3..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,29 +0,0 @@ -services: - nmapui: - build: - context: . - dockerfile: Dockerfile - image: nmapui:container - container_name: nmapui - environment: - NMAPUI_DATA_DIR: /data - NMAPUI_PORT: "9000" - ports: - - "9000:9000" - volumes: - - nmapui-data:/data - init: true - cap_add: - - NET_ADMIN - - NET_RAW - privileged: false - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/api/app-identity"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 20s - -volumes: - nmapui-data: diff --git a/docs/notes/NMAPUI_NIGHTLY_PRODUCT_EVALUATION_LOOP.md b/docs/notes/NMAPUI_NIGHTLY_PRODUCT_EVALUATION_LOOP.md new file mode 100644 index 00000000..6d1eae1b --- /dev/null +++ b/docs/notes/NMAPUI_NIGHTLY_PRODUCT_EVALUATION_LOOP.md @@ -0,0 +1,51 @@ +# NmapUI Nightly Product Evaluation Loop + +This loop is the best fit from the loop library for NmapUI because it tests the +real product surfaces the repo already automates: scan execution, report +generation, Socket.IO replay, and update flows. + +## Loop Shape + +Trigger the loop nightly or before a release candidate, then run the same small +scenario set every time: + +1. Quick scan against a safe target. +2. Deep scan with CVE output enabled. +3. HTML and PDF report generation. +4. Auto-scan schedule validation. +5. Auto-monitor rule validation. +6. Job reconnect and replay handling. +7. Update banner and idle-state flow. + +## What To Record + +- Scenario results in order. +- The target or config used. +- Generated artifact paths. +- Any blocker or warning. +- The git revision the run came from. + +## Stop Condition + +Stop only when the same scenario set passes under the same conditions and the +current runtime contract still matches the expected Socket.IO and report +behavior. + +## Runner + +Use [`scripts/nightly_product_eval.sh`](/Users/seandolbec/Projects/NmapUI/scripts/nightly_product_eval.sh) +for dry runs, recording, or the socket smoke verification pass. + +For macOS scheduling, install +[`packaging/macos/com.nmapui.nightly-product-eval.plist`](/Users/seandolbec/Projects/NmapUI/packaging/macos/com.nmapui.nightly-product-eval.plist) +into `~/Library/LaunchAgents/` and load it with `launchctl`. + +For GitHub Actions, the workflow supports both nightly scheduled runs and +manual dispatches with either `run` or `dry-run` mode. + +## Why This Loop Matters + +- It complements the existing PR loop instead of duplicating it. +- It turns the app's existing automation surface into a repeatable validation + loop. +- It gives a consistent artifact trail for regressions and release confidence. diff --git a/docs/notes/eval-logs/nightly-product-eval-server.log b/docs/notes/eval-logs/nightly-product-eval-server.log new file mode 100644 index 00000000..67db6881 --- /dev/null +++ b/docs/notes/eval-logs/nightly-product-eval-server.log @@ -0,0 +1 @@ +/Users/seandolbec/Projects/NmapUI/scripts/nightly_product_eval.sh: line 142: npm: command not found diff --git a/docs/notes/eval-logs/nightly-product-eval.launchd.err.log b/docs/notes/eval-logs/nightly-product-eval.launchd.err.log new file mode 100644 index 00000000..fc1873f6 --- /dev/null +++ b/docs/notes/eval-logs/nightly-product-eval.launchd.err.log @@ -0,0 +1 @@ +/Users/seandolbec/Projects/NmapUI/scripts/nightly_product_eval.sh: line 37: node: command not found diff --git a/docs/notes/eval-logs/nightly-product-eval.launchd.out.log b/docs/notes/eval-logs/nightly-product-eval.launchd.out.log new file mode 100644 index 00000000..e69de29b diff --git a/docs/notes/eval-logs/nightly-product-eval.log b/docs/notes/eval-logs/nightly-product-eval.log new file mode 100644 index 00000000..4aa7130a --- /dev/null +++ b/docs/notes/eval-logs/nightly-product-eval.log @@ -0,0 +1 @@ +Server did not report a listening port. diff --git a/docs/notes/native-shell-strategy.md b/docs/notes/native-shell-strategy.md new file mode 100644 index 00000000..fcb875ed --- /dev/null +++ b/docs/notes/native-shell-strategy.md @@ -0,0 +1,34 @@ +# Native Shell Strategy + +## Decision + +This project is now targeting macOS first, so a Swift-based desktop app is on the table. + +## Recommended Direction + +Keep Nmap as the external scanning engine and move the app toward a macOS-native desktop shell: + +- Use Swift/AppKit or SwiftUI for the native shell. +- Keep the scan orchestration and real-time UI behavior, but move the runtime into a packageable desktop app. +- Preserve the current Nmap subprocess model rather than trying to reimplement scanner logic in the app layer. + +For a macOS-only target, Swift is the more natural choice than a web-shell wrapper. + +## Why Swift Now Makes Sense + +- Native macOS look and behavior. +- Better fit for menu bar apps and system integration. +- No need to carry cross-platform abstraction when macOS is the only target. +- Nmap still runs externally, so the app layer can stay focused on orchestration and UI. + +## Migration Shape + +1. Keep the existing Nmap execution, reporting, and settings logic intact. +2. Move the UI into a desktop shell that can launch a local runtime. +3. Treat Nmap as an installed dependency, not an embedded library. +4. Keep scan state, logs, and report generation accessible through the desktop shell. +5. Replace platform-specific packaging last, after the app behavior is stable. + +## Current State + +The repository already contains desktop-oriented packaging work and a web-first runtime. The new `packaging/macos/` scaffold is the starting point for consolidating that runtime into a macOS-native app. diff --git a/docs/notes/python-migration-audit.md b/docs/notes/python-migration-audit.md new file mode 100644 index 00000000..d1b1b961 --- /dev/null +++ b/docs/notes/python-migration-audit.md @@ -0,0 +1,33 @@ +# Python Migration Audit + +This is the first pass at separating Python that is useful as deployment glue from Python that is part of the product surface. + +## Likely glue + +- `scripts/nightly_product_eval.sh` + - Mostly orchestration around checks, logs, and automated validation. + - Its timestamp and JSON report generation are now handled without Python. + - The socket smoke path now uses a native Node helper instead of Python. + +## Still required for parity + +- `google_drive_bridge.js` now dispatches directly to the native helper. + - The legacy `google_drive.py` helper has been removed. + - Any remaining Python usage should be treated as legacy or test-only until proven otherwise. + +## Likely product behavior + +- Any Python that participates directly in scan/report generation or user-visible export behavior should be treated as product logic, not deployment glue. +- Those paths should be migrated only after a Swift replacement proves parity on the same inputs and outputs. + +## Migration order + +1. Keep the web frontend and backend behavior unchanged. +2. Replace packaging and launch glue first. +3. Move user-visible helpers next, starting with Google Drive sync plumbing and continue until any remaining native-helper rough edges are closed. +4. Leave core scan/report semantics alone until the native replacements match them closely. + +## Current risk + +- The deployment story feels brittle when Python is used for startup or packaging glue. +- That makes the shell and launcher the best first targets for SwiftUI migration. diff --git a/docs/notes/remaining-node-surface.md b/docs/notes/remaining-node-surface.md new file mode 100644 index 00000000..49002b25 --- /dev/null +++ b/docs/notes/remaining-node-surface.md @@ -0,0 +1,94 @@ +# Remaining Node Surface + +This note is an exhaustive inventory of what `server.js` still owns versus what the Swift shell already owns. + +It is intentionally descriptive, not an authorization to keep Node around forever. + +## Already Swift-owned + +- Process launch and restart control + - `packaging/macos/Sources/NmapUIApp/RuntimeLifecycleController.swift` + - `packaging/macos/Sources/NmapUIApp/StartupCoordinator.swift` + - `packaging/macos/Sources/NmapUIApp/RuntimeBridge.swift` +- Scan execution + - `packaging/macos/Sources/NmapUIApp/ScanCoordinator.swift` +- Runtime metadata persistence + - `packaging/macos/Sources/NmapUIApp/RuntimeMetadataStore.swift` +- Runtime state snapshotting + - `packaging/macos/Sources/NmapUIApp/RuntimeScanState.swift` + - `packaging/macos/Sources/NmapUIApp/AppSessionState.swift` +- Typed request contract and first request dispatcher slice + - `packaging/macos/Sources/RuntimeContracts/RuntimeRequestContract.swift` + - `packaging/macos/Sources/NmapUIApp/RuntimeRequestDispatcher.swift` +- Report naming, report metadata, and XML parsing helpers + - `packaging/macos/Sources/NmapUIApp/RuntimeReportNaming.swift` + - `packaging/macos/Sources/NmapUIApp/RuntimeReportMetadata.swift` + - `packaging/macos/Sources/NmapUIApp/RuntimeNmapXMLSummary.swift` + +## Still Node-owned + +### Transport and socket routing + +- Socket.IO connection lifecycle +- Initial data emission +- Scan lifecycle events +- Scan stop event +- Auto-scan settings events +- History request/response routing +- Customer profile request/response routing +- Google Drive request/response routing +- Reports request/response routing +- Request parsing for typed scan-start payloads is now shared in Swift, but the live socket transport still sits in Node. + +### Scan orchestration surface + +- `sync_state` +- `scan_started` +- `phase_complete` +- `phase_stats` +- `scan_complete` +- `start_quick_scan` +- `start_complete_scan` +- `start_dragnet_scan` + +### Settings surface + +- `enable_auto_scan` +- `disable_auto_scan` +- `set_customer_profile_prefix` + +### Data surface + +- `get_history` +- `get_customer_profile` +- `get_reports` +- `get_google_drive_status` +- `save_google_drive_credentials` +- `connect_google_drive` +- `disconnect_google_drive` +- `save_google_drive_settings` + +### Helper bridge surface + +- `runRuntimeReportHelper(...)` +- `runGoogleDriveHelper(...)` +- `buildReportsSnapshot()` +- `buildRuntimeReportHistoryEntry(...)` +- `generateReportFromXml(...)` +- `getRuntimeBootstrapSnapshot()` + +## What this means + +- Swift already owns the runtime launch and scan execution core. +- Node still owns the browser-facing event router and most UI contract emission. +- Swift now owns a typed request contract and a dispatcher slice for bootstrap, settings, history, reports, and profile data. +- The next true migration step is moving the live transport/event router itself into Swift, not just moving more helper logic. + +## Migration order + +1. Move the Socket.IO event boundary. +2. Move scan state and lifecycle emission. +3. Move reports and history emission. +4. Move auto-scan and customer profile state. +5. Move Google Drive control flow. +6. Remove Node only after the UI can run against the Swift-owned contract. diff --git a/docs/notes/swiftui-migration-plan.md b/docs/notes/swiftui-migration-plan.md new file mode 100644 index 00000000..dfc90f93 --- /dev/null +++ b/docs/notes/swiftui-migration-plan.md @@ -0,0 +1,172 @@ +# SwiftUI Migration Plan + +Goal: move the macOS experience toward a SwiftUI-first shell while keeping the existing web frontend and backend behavior intact until parity is proven. + +## Backend Orchestration Plan + +This is the concrete migration path for moving backend orchestration into Swift without breaking the current product: + +1. Swift launcher and runtime contract + - Keep Node as the backend runtime for now. + - Make Swift the only code that starts, stops, restarts, and health-checks the runtime on macOS. + - Keep the loopback port and readiness URL stable. + +2. Swift-owned process and preference state + - Keep runtime configuration in Swift preferences. + - Use structured runtime settings instead of a freeform shell string. + - Preserve backward compatibility with legacy user defaults until migration is complete. + +3. Swift-owned backend services + - Move launch-at-login, open-browser, port-check, and restart behavior into Swift where possible. + - Keep the Node backend focused on scan/report generation and the web UI contract. + +4. Gradual Node reduction + - Replace backend shell helpers with direct process execution first. + - Move remaining orchestration helpers into Swift only after the Swift launcher and settings remain stable across eval runs. + - Do not rewrite Nmap scanning semantics until the orchestration boundary is stable. + +5. Final split decision + - Keep Node if it remains the clearest owner for scan/report logic. + - Move more runtime responsibilities into Swift if parity proves that the Swift path is stable and easier to maintain. + +## Current State + +- The menu bar app shell already exists in Swift under `packaging/macos/Sources/NmapUIApp/`. +- The Google Drive helper path is now native Swift-first through `GoogleDriveHelper`. +- The nightly validation loop runs without Python in the active path. +- The old Docker packaging path and the old Python helper file have been removed from the active runtime path. + +## Non-Negotiables + +- Keep the menu bar icon and native macOS app feel. +- Keep the web frontend available during the migration. +- Keep the backend behavior stable while implementation details move. +- Keep the fixed loopback port behavior stable for the launcher. + +## Migration Phases + +1. Audit Python usage + - Complete for the active runtime path. + - Any remaining Python artifacts should be treated as legacy, archived, or test-only until proven otherwise. + +2. SwiftUI app shell + - Build a native SwiftUI shell for the menu bar app, preferences, and onboarding. + - Preserve the current open-on-launch and auto-open browser behavior. + - Keep the web frontend as the primary scan/report surface for now. + +3. Swift launcher and preferences + - Move startup, restart, port checks, and settings persistence into Swift. + - Keep the runtime contract identical until the UI is fully stable. + +4. Replace Python glue + - Convert any remaining deployment helpers and maintenance scripts one by one. + - Keep the backend observable behavior unchanged while swapping implementations. + +5. Parity validation + - Verify scan start, scan completion, reports, and local UI behavior. + - Verify launch-at-login, restart, timeout handling, and browser auto-open. + - Verify the nightly eval loop after each migration slice. + +## Parity Checks + +- App launches from the macOS bundle. +- Menu bar icon appears. +- Browser opens automatically after readiness. +- `http://127.0.0.1:9000` stays the default UI entrypoint. +- First scan completes successfully. +- Reports and settings still persist correctly. + +## Recommended First Implementation Slice + +1. Finish the Swift launcher so it owns startup, readiness, restart, and browser auto-open. +2. Keep preferences and menu bar state in Swift, with structured runtime settings and legacy compatibility. +3. Leave the Node backend in place for scan/report generation until the Swift orchestration path is stable. +4. Replace remaining shell-backed helper scripts only after the Swift shell and startup flow are stable. +5. Keep the web frontend untouched until the Swift shell can launch, persist settings, and reopen reliably. + +## Verification Gates + +- `./packaging/macos/bundle.sh` builds successfully. +- `./scripts/nightly_product_eval.sh --run` completes successfully. +- `curl http://127.0.0.1:9000/api/app-identity` returns the expected app identity. +- The menu bar app opens the browser automatically when ready. +- A first scan can still be started and completed from the existing UI. +- The Swift launcher can restart the runtime without shelling out except where explicit shell behavior is required. +- Structured runtime settings survive app relaunch and preserve legacy users' existing configurations. + +## Execution Checklist + +1. Keep the current Swift launcher/runtime boundary stable. + - Owner: `RuntimeLifecycleController`, `StartupCoordinator`, `ProcessLauncher` + - Done when launch, restart, timeout, and browser auto-open still pass nightly eval. + +2. Finish migrating runtime configuration persistence. + - Owner: `PreferencesStore`, `PreferencesView` + - Done when structured executable + arguments settings are the only active path and legacy keys are only compatibility bridges. + +3. Continue reducing backend shell helpers. + - Owner: `server.js` + - Done when report generation and runtime utilities use direct process execution wherever shell expansion is not required. + - Status: complete for the current runtime surface; `server.js` now uses `execFile`/`spawn` for the remaining process launches. + +4. Decide whether Node stays the orchestration host. + - Owner: migration review, after the previous slices are stable. + - Done when we can prove whether Node remains the best owner for scan/report semantics or whether a further Swift extraction is justified. + +## Orchestration Decision + +Current recommendation: keep Node as the orchestration host for scan/report semantics for now. + +Why: + +- Node still owns the scan lifecycle, report generation, socket events, history, auto-scan, and Google Drive status plumbing. +- Swift already owns launcher lifecycle, readiness polling, restart, browser open, and preference persistence. +- The remaining Node surface is still the product runtime, not just incidental glue. + +First extraction candidate: + +- Add a Swift-native readiness and app-identity bridge only if it improves startup reliability without duplicating the Node scan/runtime contract. +- Treat that as a bridge experiment, not a wholesale runtime migration. +- Keep it behind the existing loopback contract until parity is proven. + +Decision gate for the next slice: + +- If a Swift-owned bridge can replace one Node handshake without changing scan/report behavior, move it. +- If not, keep Node in place and continue tightening the Swift shell and backend process boundaries. + +Next implementation slice: + +1. Add a Swift-owned readiness check helper that can validate the loopback runtime state independently of the web UI. +2. Keep `/api/app-identity` in Node for now, but introduce a Swift-side probe path that can prove the runtime is reachable before the browser opens. +3. Use the existing nightly eval loop to confirm that the bridge does not change startup timing, browser auto-open, or scan initiation. + +Bridge status: + +- `RuntimeEndpoints.readinessURL` now points at `/api/app-identity` so the Swift launcher can prove the Node runtime is reachable with an existing contract. +- `RuntimeBridge` now probes TCP reachability before it polls the identity endpoint, which makes startup failures easier to distinguish from contract failures. +- `RuntimeIdentity` is now a typed Swift representation of the Node identity payload, so the bridge is explicit instead of being a generic JSON poll. +- `RuntimeBridge` now owns the typed readiness probe used by the Swift launcher. +- The native shell now surfaces the resolved backend identity in session state, so the Swift side owns the user-facing view of the runtime contract. +- This is a bridge experiment, not a replacement for the Node backend runtime. + +## Backend Replacement Slice + +The backend replacement plan is now concrete enough to implement incrementally: + +1. Keep the macOS launch path and startup metadata in Swift. +2. Treat `runtime-identity.json` as the first shared contract file written by Swift and read by Node. +3. Keep Node as the scan/report runtime until an equivalent Swift-owned service is ready. +4. Move helper-style integrations behind Swift-owned stores or facades before touching scan semantics. +5. Replace runtime endpoints one contract at a time, preserving the existing loopback URLs and nightly eval checks. + +Current implementation slice: + +- `RuntimeMetadataStore` centralizes runtime identity file persistence for the native launcher. +- `server.js` reads the launcher-written runtime identity file when it is present and falls back to the legacy identity payload otherwise. +- This narrows Node's role in the app identity handshake without changing the scan/report behavior. + +Current helper contract slice: + +- `RuntimeCapabilities` centralizes startup-visible helper availability for the native launcher. +- `RuntimeMetadataStore` now persists `runtime-capabilities.json` alongside `runtime-identity.json`. +- The Google Drive helper is still executed separately today, but its availability is now a shared startup contract that Swift can own and refresh when runtime identity resolves. The native shell shows that state directly in its header so the contract is user-visible. diff --git a/docs/notes/transport-rewrite-map.md b/docs/notes/transport-rewrite-map.md new file mode 100644 index 00000000..6f6201a4 --- /dev/null +++ b/docs/notes/transport-rewrite-map.md @@ -0,0 +1,116 @@ +# Transport Rewrite Map + +This note turns the remaining Node-owned Socket.IO surface into a concrete Swift migration sequence. + +It is a migration aid, not a substitute for the actual transport rewrite. + +## Current state + +- Swift already owns the process launcher, runtime startup checks, scan execution, and shared runtime contracts. +- Node still owns the live Socket.IO connection, event dispatch, and browser-facing UI contract. +- The shared Swift event router now provides a typed place to emit future transport messages. +- Swift also now owns a typed client request contract and a first request-dispatcher slice for bootstrap, data, settings, and scan-start parsing. +- Swift now also owns the typed `scan_stopped` request response path. + +## Event families to replace + +### Connection and bootstrap + +- `get_initial_data` +- `initial_data` +- `sync_state` + +Target Swift owners: +- `RuntimeEventRouter` +- `RuntimeInitialDataEnvelope` +- `RuntimeScanLifecycleEnvelope` + +### Scan lifecycle + +- `start_quick_scan` +- `start_complete_scan` +- `start_dragnet_scan` +- `scan_started` +- `phase_complete` +- `phase_stats` +- `scan_complete` +- `scan_stopped` + +Target Swift owners: +- `ScanCoordinator` +- `RuntimeEventRouter` +- `RuntimePhaseCompleteEnvelope` +- `RuntimePhaseStatsEnvelope` +- `RuntimeScanLifecycleEnvelope` + +### Discovery and traceroute + +- `discovery_update` +- `traceroute_hop` + +Target Swift owners: +- `RuntimeEventRouter` +- `RuntimeDiscoveryUpdateEnvelope` +- `RuntimeTracerouteHopEnvelope` + +### Reports and history + +- `get_history` +- `history_data` +- `get_reports` +- `reports_data` +- `report_ready` +- `reports_refresh` +- `log_entry` + +Target Swift owners: +- `RuntimeMetadataStore` +- `RuntimeReportHelper` +- `RuntimeEventRouter` +- `RuntimeReportReadyEnvelope` + +### Customer profile and auto-scan + +- `get_customer_profile` +- `customer_profile` +- `set_customer_profile_prefix` +- `enable_auto_scan` +- `disable_auto_scan` +- `auto_scan_config` + +Target Swift owners: +- `RuntimeCustomerProfile` +- `RuntimeEventRouter` +- `RuntimeInitialDataEnvelope` + +### Google Drive + +- `get_google_drive_status` +- `google_drive_status` +- `google_drive_auth_url` +- `save_google_drive_credentials` +- `connect_google_drive` +- `disconnect_google_drive` +- `save_google_drive_settings` + +Target Swift owners: +- `RuntimeGoogleDriveStatusEnvelope` +- `RuntimeEventRouter` +- `RuntimeReportMetadata` + +## Rewrite sequence + +1. Replace bootstrap/state emissions with Swift-owned event construction. +2. Move scan lifecycle emission out of Node and into Swift. +3. Move report/history emission into Swift. +4. Move customer profile and auto-scan emissions into Swift. +5. Move Google Drive status/auth flows into Swift. +6. Remove the Node event router only after the Swift transport path can serve the same UI contract. + +## Verification gates + +- `swift test` +- `node --check server.js` +- A full scan can still start and complete during the transition. +- The existing web UI still receives the same event payload shapes. +- The loopback readiness contract stays stable while the transport boundary moves. diff --git a/docs/notes/vapor-migration-plan.md b/docs/notes/vapor-migration-plan.md new file mode 100644 index 00000000..5488b244 --- /dev/null +++ b/docs/notes/vapor-migration-plan.md @@ -0,0 +1,135 @@ +# Vapor Migration Plan + +This is the follow-on plan for the day after the Swift migration is complete. + +It is intentionally separate from the current Swift migration work. Do not use this plan to justify delaying the remaining Swift transport replacement work. + +## Purpose + +Move backend orchestration from a local Node runtime into a Swift server built with Vapor, once the macOS app is already stable in Swift and the Node transport shim is no longer carrying the product runtime. + +The goal of this phase is not to “make the app more Swift” in the abstract. The goal is to create a maintainable backend service boundary that can own: + +- asset tracking +- scan history +- scan scheduling +- report persistence +- sync state +- API surface for future clients + +## When this plan becomes relevant + +Only start this migration after all of the following are true: + +- The current Swift migration is complete and verified. +- The macOS app no longer depends on `server.js` for live transport. +- The product can launch, scan, stop, report, and sync without Node acting as the runtime control plane. +- The current loopback or equivalent client contract has a stable replacement boundary in Swift. + +## Target architecture + +### Client side + +- Keep the macOS app as the local shell and launcher. +- Keep the app responsible for startup, readiness, and local user interaction. +- Keep the client-facing runtime contract explicit and typed. + +### Vapor backend + +- Use Vapor as the backend service for runtime orchestration and data APIs. +- Expose structured endpoints for: + - scan lifecycle control + - asset inventory + - report lookup and download + - historical scan data + - customer profile or tenancy metadata + - sync state and health checks +- Keep the backend implementation stateless where possible, with durable storage owning the source of truth. + +### Shared contracts + +- Define the request and event envelopes as shared Swift models. +- Keep the wire contract versioned and backward compatible. +- Treat the macOS client and Vapor backend as separate executables with a shared contract layer, not as two copies of the same runtime logic. + +## Migration phases + +### Phase 1: contract extraction + +- Move the remaining runtime message shapes into a shared Swift module. +- Separate transport envelopes from runtime behavior. +- Define versioning for request and event payloads. +- Add compatibility fallbacks for older client payloads only where needed. + +### Phase 2: backend skeleton + +- Create a Vapor service target. +- Add health and readiness endpoints first. +- Add a minimal identity endpoint that replaces the current runtime handshake. +- Wire local development so the macOS app can point at Vapor without changing the client UI contract. + +### Phase 3: data ownership + +- Move persisted metadata into a durable backend store. +- Add repositories or service layers for: + - history + - reports + - scan metadata + - asset records + - customer profile data +- Keep file formats and download paths stable during the transition. + +### Phase 4: orchestration endpoints + +- Add scan start, stop, and status endpoints. +- Add scheduling controls for auto-scan. +- Add Google Drive sync controls if they remain backend-owned. +- Emit event updates through the Vapor service instead of through a local Socket.IO runtime. + +### Phase 5: client cutover + +- Point the macOS shell at the Vapor backend. +- Keep the old Node path behind a feature flag or development fallback until parity is proven. +- Verify the app can boot, scan, and show history against the Vapor backend only. + +### Phase 6: Node removal + +- Remove the Node runtime from the main path. +- Delete Node-only helper scripts once the Vapor replacement is proven. +- Archive any remaining Node-specific implementation notes. + +## Suggested implementation order + +1. Define the shared contract module. +2. Stand up a Vapor health server. +3. Move identity and readiness checks. +4. Move history and report lookup. +5. Move scan lifecycle endpoints. +6. Move scheduling and sync controls. +7. Cut the macOS client over. +8. Remove Node from the production path. + +## Verification gates + +- Vapor service starts locally and passes a readiness check. +- The macOS client can connect to the Vapor backend without changing the UI contract. +- Existing scan and report workflows still complete end to end. +- History and report data remain stable after the cutover. +- The nightly evaluation loop can run against the new backend. +- Node can be disabled without breaking the primary user path. + +## Risks and constraints + +- Do not start Vapor work before the Swift migration is actually done. +- Do not duplicate backend logic between Node and Vapor for long periods. +- Do not change the user-visible contract unless the new one is versioned and verified. +- Keep the migration incremental so the macOS app can remain usable during the transition. + +## Exit criteria + +The Vapor migration is complete when: + +- the macOS app no longer needs Node as a runtime dependency, +- the Vapor backend owns the remaining orchestration and data services, +- and the full product flow is verified against the Swift/Vapor stack. + diff --git a/google_drive.py b/google_drive.py deleted file mode 100644 index 3e0a0dde..00000000 --- a/google_drive.py +++ /dev/null @@ -1,724 +0,0 @@ -import base64 -import hashlib -import json -import os -import secrets -from datetime import datetime, timedelta, timezone -from pathlib import Path -from urllib.parse import urlencode -from urllib.request import Request, urlopen - -from cryptography.fernet import Fernet, InvalidToken - - -GOOGLE_DRIVE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth" -GOOGLE_DRIVE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" -GOOGLE_DRIVE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke" -GOOGLE_DRIVE_UPLOAD_ENDPOINT = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,webViewLink" -GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file" -GOOGLE_DRIVE_FILES_ENDPOINT = "https://www.googleapis.com/drive/v3/files" -GOOGLE_DRIVE_FOLDER_MIME = "application/vnd.google-apps.folder" - - -def _format_google_drive_error(payload: dict) -> str: - if not isinstance(payload, dict): - return "Unknown error" - error = payload.get("error") - if isinstance(error, dict): - message = error.get("message") or error.get("status") or "Google Drive API error" - reasons = [] - for entry in error.get("errors", []) or []: - if isinstance(entry, dict) and entry.get("reason"): - reasons.append(entry["reason"]) - if reasons: - unique = ", ".join(sorted(set(reasons))) - return f"{message} ({unique})" - return message - if isinstance(error, str): - return error - return payload.get("error_description") or payload.get("message") or "Unknown error" - - -def _response_json(response) -> dict: - try: - payload = response.json() - except Exception: - return {} - return payload if isinstance(payload, dict) else {} - - -class _UrllibResponse: - def __init__(self, status_code: int, body: bytes): - self.status_code = status_code - self._body = body - - def json(self): - return json.loads(self._body.decode("utf-8") or "{}") - - -class _UrllibRequests: - @staticmethod - def _request(method: str, url: str, *, headers=None, body=None, timeout=10): - request = Request(url, data=body, headers=headers or {}, method=method) - try: - with urlopen(request, timeout=timeout) as response: - return _UrllibResponse(response.status, response.read()) - except Exception as exc: - if hasattr(exc, "code") and hasattr(exc, "read"): - return _UrllibResponse(exc.code, exc.read()) - raise - - @staticmethod - def get(url: str, *, headers=None, params=None, timeout=10): - if params: - separator = "&" if "?" in url else "?" - url = f"{url}{separator}{urlencode(params)}" - return _UrllibRequests._request("GET", url, headers=headers, timeout=timeout) - - @staticmethod - def post(url: str, *, data=None, json=None, params=None, headers=None, files=None, timeout=10): - if params: - separator = "&" if "?" in url else "?" - url = f"{url}{separator}{urlencode(params)}" - - request_headers = dict(headers or {}) - if files: - boundary = f"----gemini-nmap-{secrets.token_hex(16)}" - body_parts = [] - for field_name, file_tuple in files.items(): - filename, content, content_type = file_tuple - if hasattr(content, "read"): - content = content.read() - if isinstance(content, str): - content = content.encode("utf-8") - body_parts.extend([ - f"--{boundary}\r\n".encode("utf-8"), - f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode("utf-8"), - f"Content-Type: {content_type}\r\n\r\n".encode("utf-8"), - content, - b"\r\n", - ]) - body_parts.append(f"--{boundary}--\r\n".encode("utf-8")) - body = b"".join(body_parts) - request_headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" - elif json is not None: - body = __import__("json").dumps(json).encode("utf-8") - request_headers["Content-Type"] = "application/json" - else: - body = urlencode(data or {}).encode("utf-8") - request_headers["Content-Type"] = "application/x-www-form-urlencoded" - - return _UrllibRequests._request("POST", url, headers=request_headers, body=body, timeout=timeout) - - -def _load_requests_module(): - try: - import requests - return requests - except ModuleNotFoundError: - return _UrllibRequests - - -ENCRYPTED_TOKEN_SCHEMA_VERSION = 1 - - -def _load_json_file(path: Path, default): - if not path.exists(): - return default - try: - return json.loads(path.read_text()) - except Exception: - return default - - -def _save_json_file(path: Path, payload: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.with_suffix(f"{path.suffix}.tmp") - tmp_path.write_text(json.dumps(payload, indent=2)) - tmp_path.replace(path) - - -def _set_owner_only_permissions(path: Path) -> None: - try: - os.chmod(path, 0o600) - except OSError: - pass - - -def _load_or_create_encryption_key(key_path: Path) -> bytes: - key_path.parent.mkdir(parents=True, exist_ok=True) - if key_path.exists(): - key = key_path.read_bytes().strip() - _set_owner_only_permissions(key_path) - return key - - key = Fernet.generate_key() - tmp_path = key_path.with_suffix(f"{key_path.suffix}.tmp") - tmp_path.write_bytes(key) - tmp_path.replace(key_path) - _set_owner_only_permissions(key_path) - return key - - -def _load_encrypted_token_payload(token_path: Path, key_path: Path): - if not token_path.exists(): - return None - payload = _load_json_file(token_path, {}) - if not isinstance(payload, dict) or "ciphertext" not in payload: - return None - - ciphertext = payload.get("ciphertext") - if not ciphertext: - return {} - - key = _load_or_create_encryption_key(key_path) - decrypted = Fernet(key).decrypt(str(ciphertext).encode("utf-8")) - decoded = json.loads(decrypted.decode("utf-8")) - return decoded if isinstance(decoded, dict) else {} - - -def load_google_drive_credentials(credentials_path: Path) -> dict: - payload = _load_json_file(credentials_path, {}) - if "installed" in payload: - payload = payload["installed"] - if "web" in payload: - payload = payload["web"] - return payload if isinstance(payload, dict) else {} - - -def save_google_drive_credentials(credentials_path: Path, payload: dict) -> dict: - if not isinstance(payload, dict): - return {"success": False, "error": "Invalid credentials payload"} - _save_json_file(credentials_path, payload) - _set_owner_only_permissions(credentials_path) - return {"success": True, "status": "Google Drive credentials saved"} - - -def load_google_drive_token_state(token_path: Path, key_path: Path | None = None) -> dict: - key_path = key_path or token_path.with_suffix(".key") - try: - encrypted_payload = _load_encrypted_token_payload(token_path, key_path) - except InvalidToken: - return {} - if encrypted_payload is not None: - return encrypted_payload - - payload = _load_json_file(token_path, {}) - if not isinstance(payload, dict): - return {} - if payload: - save_google_drive_token_state(token_path, payload, key_path=key_path) - return payload - - -def save_google_drive_token_state(token_path: Path, payload: dict, key_path: Path | None = None) -> dict: - key_path = key_path or token_path.with_suffix(".key") - key = _load_or_create_encryption_key(key_path) - encrypted_payload = Fernet(key).encrypt(json.dumps(payload).encode("utf-8")).decode("utf-8") - _save_json_file( - token_path, - { - "schema_version": ENCRYPTED_TOKEN_SCHEMA_VERSION, - "ciphertext": encrypted_payload, - }, - ) - _set_owner_only_permissions(token_path) - return payload - - -def clear_google_drive_token_state(token_path: Path, key_path: Path | None = None) -> None: - if token_path.exists(): - token_path.unlink() - key_path = key_path or token_path.with_suffix(".key") - if key_path.exists(): - key_path.unlink() - - -def build_google_drive_auth_status(*, credentials_path: Path, token_path: Path, key_path: Path | None = None) -> dict: - credentials = load_google_drive_credentials(credentials_path) - token_state = load_google_drive_token_state(token_path, key_path=key_path) - has_refresh_token = bool(token_state.get("refresh_token")) - has_access_token = bool(token_state.get("access_token")) - expires_at = token_state.get("expires_at") - configured = bool(credentials.get("client_id") and credentials.get("client_secret")) - if has_refresh_token or has_access_token: - status = "Connected" - elif configured: - status = "Not connected" - else: - status = "OAuth credentials missing. Import credentials.json to enable Google Drive." - return { - "configured": configured, - "connected": has_refresh_token or has_access_token, - "expires_at": expires_at, - "status": status, - } - - -def _build_code_verifier() -> str: - return secrets.token_urlsafe(48) - - -def _build_code_challenge(code_verifier: str) -> str: - digest = hashlib.sha256(code_verifier.encode("utf-8")).digest() - return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") - - -def build_google_drive_auth_url( - *, - credentials_path: Path, - token_path: Path, - key_path: Path | None = None, - redirect_uri: str, -) -> dict: - credentials = load_google_drive_credentials(credentials_path) - if not credentials.get("client_id") or not credentials.get("client_secret"): - return { - "success": False, - "error": "Google Drive OAuth credentials file not found. Upload your credentials.json from the Google Cloud Console.", - } - - state = secrets.token_urlsafe(24) - code_verifier = _build_code_verifier() - code_challenge = _build_code_challenge(code_verifier) - token_state = load_google_drive_token_state(token_path, key_path=key_path) - token_state["pending_auth"] = { - "state": state, - "code_verifier": code_verifier, - "redirect_uri": redirect_uri, - "created_at": datetime.now(timezone.utc).isoformat(), - } - save_google_drive_token_state(token_path, token_state, key_path=key_path) - - query = urlencode( - { - "client_id": credentials["client_id"], - "redirect_uri": redirect_uri, - "response_type": "code", - "scope": GOOGLE_DRIVE_SCOPE, - "access_type": "offline", - "prompt": "consent", - "include_granted_scopes": "true", - "state": state, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - } - ) - return {"success": True, "auth_url": f"{GOOGLE_DRIVE_AUTH_ENDPOINT}?{query}"} - - -def exchange_google_drive_auth_code( - *, - credentials_path: Path, - token_path: Path, - key_path: Path | None = None, - code: str, - state: str, - requests_module, -) -> dict: - credentials = load_google_drive_credentials(credentials_path) - token_state = load_google_drive_token_state(token_path, key_path=key_path) - pending_auth = token_state.get("pending_auth") or {} - if not pending_auth or pending_auth.get("state") != state: - return {"success": False, "error": "Invalid or expired Google Drive auth state."} - - created_at_str = pending_auth.get("created_at", "") - if created_at_str: - try: - created_at = datetime.fromisoformat(created_at_str) - if datetime.now(timezone.utc) - created_at > timedelta(minutes=10): - return {"success": False, "error": "Google Drive auth state has expired. Please restart the auth flow."} - except ValueError: - pass - - try: - response = requests_module.post( - GOOGLE_DRIVE_TOKEN_ENDPOINT, - data={ - "client_id": credentials.get("client_id", ""), - "client_secret": credentials.get("client_secret", ""), - "code": code, - "code_verifier": pending_auth.get("code_verifier", ""), - "grant_type": "authorization_code", - "redirect_uri": pending_auth.get("redirect_uri", ""), - }, - timeout=10, - ) - except Exception as exc: - return {"success": False, "error": f"Failed to reach Google Drive token endpoint: {exc}"} - payload = _response_json(response) - if response.status_code >= 400 or "access_token" not in payload: - return { - "success": False, - "error": payload.get("error_description") - or payload.get("error") - or f"Failed to exchange Google Drive auth code (HTTP {response.status_code}).", - } - - expires_in = int(payload.get("expires_in") or 3600) - token_state.update( - { - "access_token": payload.get("access_token"), - "refresh_token": payload.get("refresh_token") or token_state.get("refresh_token"), - "scope": payload.get("scope", GOOGLE_DRIVE_SCOPE), - "token_type": payload.get("token_type", "Bearer"), - "expires_at": (datetime.now(timezone.utc) + timedelta(seconds=expires_in)).isoformat(), - } - ) - token_state.pop("pending_auth", None) - save_google_drive_token_state(token_path, token_state, key_path=key_path) - return {"success": True, "status": "Google Drive connected"} - - -def _token_is_expired(token_state: dict) -> bool: - expires_at = token_state.get("expires_at") - if not expires_at: - return False - try: - expiry = datetime.fromisoformat(expires_at) - except ValueError: - return True - return expiry <= (datetime.now(timezone.utc) + timedelta(minutes=1)) - - -def ensure_google_drive_access_token( - *, - credentials_path: Path, - token_path: Path, - key_path: Path | None = None, - requests_module, -) -> str: - token_state = load_google_drive_token_state(token_path, key_path=key_path) - access_token = token_state.get("access_token") - if access_token and not _token_is_expired(token_state): - return access_token - - refresh_token = token_state.get("refresh_token") - if not refresh_token: - raise RuntimeError("Google Drive is not connected.") - - credentials = load_google_drive_credentials(credentials_path) - try: - response = requests_module.post( - GOOGLE_DRIVE_TOKEN_ENDPOINT, - data={ - "client_id": credentials.get("client_id", ""), - "client_secret": credentials.get("client_secret", ""), - "refresh_token": refresh_token, - "grant_type": "refresh_token", - }, - timeout=10, - ) - except Exception as exc: - raise RuntimeError(f"Failed to refresh Google Drive access token: {exc}") from exc - payload = _response_json(response) - if response.status_code >= 400 or "access_token" not in payload: - raise RuntimeError( - payload.get("error_description") - or payload.get("error") - or f"Failed to refresh Google Drive access token (HTTP {response.status_code})." - ) - - expires_in = int(payload.get("expires_in") or 3600) - token_state.update( - { - "access_token": payload.get("access_token"), - "token_type": payload.get("token_type", "Bearer"), - "expires_at": (datetime.now(timezone.utc) + timedelta(seconds=expires_in)).isoformat(), - } - ) - save_google_drive_token_state(token_path, token_state, key_path=key_path) - return token_state["access_token"] - - -def disconnect_google_drive( - *, - token_path: Path, - key_path: Path | None = None, - requests_module, -) -> dict: - token_state = load_google_drive_token_state(token_path, key_path=key_path) - token = token_state.get("refresh_token") or token_state.get("access_token") - if token: - try: - requests_module.post( - GOOGLE_DRIVE_REVOKE_ENDPOINT, - params={"token": token}, - timeout=10, - ) - except Exception: - pass - clear_google_drive_token_state(token_path, key_path=key_path) - return {"success": True, "status": "Google Drive disconnected"} - - -def upload_files_to_google_drive( - *, - credentials_path: Path, - token_path: Path, - key_path: Path | None = None, - file_paths: list[Path], - folder_id: str, - file_name_map: dict[str, str] | None = None, - requests_module, -) -> dict: - access_token = ensure_google_drive_access_token( - credentials_path=credentials_path, - token_path=token_path, - key_path=key_path, - requests_module=requests_module, - ) - uploaded = [] - for file_path in file_paths: - override_name = (file_name_map or {}).get(str(file_path)) - metadata = {"name": override_name or file_path.name} - if folder_id: - metadata["parents"] = [folder_id] - with file_path.open("rb") as file_handle: - try: - response = requests_module.post( - GOOGLE_DRIVE_UPLOAD_ENDPOINT, - headers={ - "Authorization": f"Bearer {access_token}", - }, - files={ - "metadata": ( - "metadata.json", - json.dumps(metadata).encode("utf-8"), - "application/json; charset=UTF-8", - ), - "file": (file_path.name, file_handle, "application/octet-stream"), - }, - timeout=30, - ) - except Exception as exc: - raise RuntimeError(f"Drive upload failed for {file_path.name}: {exc}") from exc - payload = _response_json(response) - if response.status_code >= 400: - raise RuntimeError( - payload.get("error", {}).get("message") - or f"Drive upload failed for {file_path.name} (HTTP {response.status_code})." - ) - uploaded.append(payload) - - return { - "success": True, - "uploaded": uploaded, - "status": f"Uploaded {len(uploaded)} file(s) to Google Drive", - } - - -def ensure_google_drive_reports_folder( - *, - credentials_path: Path, - token_path: Path, - key_path: Path | None = None, - requests_module, - folder_name: str = "nmapui-reports", - parent_id: str | None = None, -) -> dict: - access_token = ensure_google_drive_access_token( - credentials_path=credentials_path, - token_path=token_path, - key_path=key_path, - requests_module=requests_module, - ) - headers = {"Authorization": f"Bearer {access_token}"} - escaped_name = folder_name.replace("\\", "\\\\").replace("'", "\\'") - query_parts = [ - "mimeType='application/vnd.google-apps.folder'", - f"name='{escaped_name}'", - "trashed=false", - ] - if parent_id: - query_parts.append(f"'{parent_id}' in parents") - query = " and ".join(query_parts) - try: - response = requests_module.get( - GOOGLE_DRIVE_FILES_ENDPOINT, - headers=headers, - params={"q": query, "fields": "files(id,name,webViewLink)"}, - timeout=10, - ) - except Exception as exc: - return {"success": False, "error": f"Failed to query Drive folder: {exc}"} - payload = _response_json(response) - if response.status_code >= 400: - error_detail = _format_google_drive_error(payload) - return { - "success": False, - "error": f"Failed to query Drive folder (HTTP {response.status_code}): {error_detail}", - } - - files = payload.get("files") or [] - if files: - folder_id = files[0].get("id") - return {"success": True, "folder_id": folder_id, "folder": files[0], "status": "Drive folder ready"} - - try: - create_response = requests_module.post( - GOOGLE_DRIVE_FILES_ENDPOINT, - headers={**headers, "Content-Type": "application/json"}, - json={ - "name": folder_name, - "mimeType": GOOGLE_DRIVE_FOLDER_MIME, - **({"parents": [parent_id]} if parent_id else {}), - }, - timeout=10, - ) - except Exception as exc: - return {"success": False, "error": f"Failed to create Drive folder: {exc}"} - create_payload = _response_json(create_response) - if create_response.status_code >= 400: - error_detail = _format_google_drive_error(create_payload) - return { - "success": False, - "error": f"Failed to create Drive folder (HTTP {create_response.status_code}): {error_detail}", - } - return { - "success": True, - "folder_id": create_payload.get("id"), - "folder": create_payload, - "status": "Drive folder created", - } - - -def create_google_drive_folder( - *, - name: str, - parent_id: str | None, - credentials_path: Path, - token_path: Path, - key_path: Path | None = None, - requests_module, -) -> dict: - access_token = ensure_google_drive_access_token( - credentials_path=credentials_path, - token_path=token_path, - key_path=key_path, - requests_module=requests_module, - ) - headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"} - payload = {"name": name, "mimeType": GOOGLE_DRIVE_FOLDER_MIME} - if parent_id: - payload["parents"] = [parent_id] - try: - response = requests_module.post( - GOOGLE_DRIVE_FILES_ENDPOINT, - headers=headers, - json=payload, - timeout=10, - ) - except Exception as exc: - return {"success": False, "error": f"Failed to create Drive folder: {exc}"} - create_payload = _response_json(response) - if response.status_code >= 400: - error_detail = _format_google_drive_error(create_payload) - return { - "success": False, - "error": f"Failed to create Drive folder (HTTP {response.status_code}): {error_detail}", - } - return { - "success": True, - "folder_id": create_payload.get("id"), - "status": "Drive folder created", - } - - -def _default_paths(root: Path) -> dict[str, Path]: - state_dir = root / ".google_drive" - return { - "credentials_path": state_dir / "credentials.json", - "token_path": state_dir / "token.json", - "key_path": state_dir / "token.key", - } - - -def _print_json(payload: dict) -> None: - print(json.dumps(payload)) - - -def main() -> int: - import argparse - import sys - - parser = argparse.ArgumentParser(description="Google Drive helper for NmapUI reports") - parser.add_argument("command", choices=["status", "save-credentials", "auth-url", "exchange-code", "disconnect", "upload"]) - parser.add_argument("--root", default=str(Path(__file__).resolve().parent)) - parser.add_argument("--redirect-uri", default="") - parser.add_argument("--code", default="") - parser.add_argument("--state", default="") - parser.add_argument("--folder-id", default="") - parser.add_argument("--folder-name", default="Nmap Reports") - parser.add_argument("--day-folder-name", default="") - parser.add_argument("--files", nargs="*", default=[]) - parser.add_argument("--credentials-json", default="") - args = parser.parse_args() - - root = Path(args.root).resolve() - paths = _default_paths(root) - requests_module = _load_requests_module() - - try: - if args.command == "status": - _print_json(build_google_drive_auth_status(**paths)) - return 0 - - if args.command == "save-credentials": - payload = json.loads(args.credentials_json or sys.stdin.read() or "{}") - _print_json(save_google_drive_credentials(paths["credentials_path"], payload)) - return 0 - - if args.command == "auth-url": - _print_json(build_google_drive_auth_url(redirect_uri=args.redirect_uri, **paths)) - return 0 - - if args.command == "exchange-code": - _print_json(exchange_google_drive_auth_code(code=args.code, state=args.state, requests_module=requests_module, **paths)) - return 0 - - if args.command == "disconnect": - _print_json(disconnect_google_drive(token_path=paths["token_path"], key_path=paths["key_path"], requests_module=requests_module)) - return 0 - - if args.command == "upload": - folder_id = args.folder_id - if not folder_id: - folder_result = ensure_google_drive_reports_folder(folder_name=args.folder_name, requests_module=requests_module, **paths) - if not folder_result.get("success"): - _print_json(folder_result) - return 1 - folder_id = folder_result.get("folder_id") or "" - upload_folder_id = folder_id - day_folder_id = "" - if args.day_folder_name: - day_folder_result = ensure_google_drive_reports_folder( - folder_name=args.day_folder_name, - parent_id=folder_id, - requests_module=requests_module, - **paths, - ) - if not day_folder_result.get("success"): - _print_json(day_folder_result) - return 1 - day_folder_id = day_folder_result.get("folder_id") or "" - upload_folder_id = day_folder_id - result = upload_files_to_google_drive( - file_paths=[Path(file_path).resolve() for file_path in args.files], - folder_id=upload_folder_id, - requests_module=requests_module, - **paths, - ) - result["folder_id"] = folder_id - result["day_folder_id"] = day_folder_id - _print_json(result) - return 0 - except Exception as exc: - _print_json({"success": False, "error": str(exc)}) - return 1 - - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/google_drive_bridge.js b/google_drive_bridge.js new file mode 100644 index 00000000..ce87a0e9 --- /dev/null +++ b/google_drive_bridge.js @@ -0,0 +1,35 @@ +const { spawn } = require('child_process'); +const path = require('path'); + +function runGoogleDriveHelper(args, dataDir, input = null) { + return new Promise((resolve) => { + const helperCommand = process.env.NMAPUI_GOOGLE_DRIVE_HELPER + || path.join(__dirname, 'packaging/macos/.build/out/Products/Debug/GoogleDriveHelper'); + const child = spawn(helperCommand, [...args, '--root', dataDir]); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', data => { stdout += data.toString(); }); + child.stderr.on('data', data => { stderr += data.toString(); }); + child.on('close', (code) => { + try { + const payload = JSON.parse(stdout.trim() || '{}'); + resolve({ ...payload, exitCode: code }); + } catch (error) { + resolve({ success: false, exitCode: code, error: stderr.trim() || stdout.trim() || error.message }); + } + }); + + if (input) child.stdin.end(input); + else child.stdin.end(); + }); +} + +function getGoogleDriveRedirectUri(port) { + return `http://localhost:${port}/google-drive/oauth2callback`; +} + +module.exports = { + runGoogleDriveHelper, + getGoogleDriveRedirectUri, +}; diff --git a/google_drive_status.js b/google_drive_status.js new file mode 100644 index 00000000..437707fe --- /dev/null +++ b/google_drive_status.js @@ -0,0 +1,28 @@ +function googleDriveHelperUnavailableStatus(getGoogleDriveConfig, payload = {}) { + return { + success: false, + connected: false, + configured: false, + status: 'Google Drive helper unavailable', + config: getGoogleDriveConfig(), + ...payload + }; +} + +function emitGoogleDriveHelperUnavailable(socket, getGoogleDriveConfig, payload = {}) { + socket.emit('google_drive_status', googleDriveHelperUnavailableStatus(getGoogleDriveConfig, payload)); +} + +function requireGoogleDriveHelper(socket, loadRuntimeCapabilities, getGoogleDriveConfig, payload = {}) { + if (loadRuntimeCapabilities().googleDriveHelperAvailable) { + return true; + } + emitGoogleDriveHelperUnavailable(socket, getGoogleDriveConfig, payload); + return false; +} + +module.exports = { + googleDriveHelperUnavailableStatus, + emitGoogleDriveHelperUnavailable, + requireGoogleDriveHelper, +}; diff --git a/index.html b/index.html index d8d2d147..3dca5cdb 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,24 @@ - + - - - - - - - - - - - - - - + + + + + + + + + + + + + +