diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..2bee9733
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,24 @@
+.git
+.gitignore
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.venv/
+venv/
+data/
+logs/
+build/
+dist/
+*.log
+node_modules/
+tmp/
+tmp-*/
+*.sqlite3
+*.db
+*.DS_Store
+coverage.xml
+htmlcov/
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/README.md b/README.md
index 49d0ea89..4d519ac7 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# TM-NMapUI
-Network Scanner GUI - A web-based network scanning and monitoring tool powered by Nmap.
+macOS-first network scanning and monitoring app powered by Nmap.
## Quick Start
@@ -11,7 +11,7 @@ cd TM-NmapUI
sudo npm start
```
-Then open http://localhost:9000 in your browser.
+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.
@@ -20,6 +20,7 @@ Then open http://localhost:9000 in your browser.
| Layer | Technology |
|-------|------------|
| Runtime | Node.js |
+| Desktop Shell | Swift menu bar app |
| Web Framework | Express |
| Real-time | Socket.IO |
| Scanner | Nmap + NSE (Nmap Scripting Engine) |
@@ -27,29 +28,94 @@ Then open http://localhost:9000 in your browser.
| XML Processing | xml2js |
| Scheduling | node-cron |
| HTTP Client | axios |
-| Cloud Sync | Google Drive API (Python) |
+| Cloud Sync | Google Drive helper |
## Requirements
-- **macOS** (tested on macOS)
+- **macOS** (primary target)
- **Homebrew** (for package management)
- **Node.js** (via Homebrew)
- **Nmap** (with script database updated)
-- **Python 3** (for Google Drive integration)
- **wkhtmltopdf** or **Chromium** (for PDF generation)
- **xsltproc** (for HTML report styling)
All dependencies are installed automatically by `install.sh`.
+## Installation & Quick Start (macOS)
+
+1. Clone the repository:
+ ```bash
+ git clone https://github.com/techmore/NmapUI.git
+ cd NmapUI
+ ```
+
+2. Build and launch the menu bar app:
+ ```bash
+ open NmapUIMenuBar.app
+ ```
+
+ After launch, look for the network icon in your macOS menu bar. The app serves NmapUI on a local loopback URL, defaulting to `http://127.0.0.1:9000` and falling back to the next available local port if needed.
+ The menu bar app now exposes a `Launch at Login` toggle and an `Uninstall NmapUI` menu action. Uninstall removes the login item registration first and then moves the app bundle to the Trash.
+
+ > **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.
+
+## Repository Layout
+
+- Root: stable entrypoints and runtime files such as `server.js`, `install.sh`, and `deploy.sh`
+- `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
+- `docs/notes/`: internal implementation notes and working analysis
+- `docs/audits/`: deeper audit writeups that are not part of the main setup flow
+
+Runtime-only files such as `auto_scan_config.json`, generated scan outputs, local wrapper binaries, and ad hoc scratch directories should stay untracked.
+
+## Admin Commands
+
+Export the runtime database from the Settings tab, or download it directly:
+
+```bash
+curl -OJ http://127.0.0.1:9000/api/runtime/export
+```
+
+If you are migrating an existing runtime database into the menu bar app bundle, copy the runtime data directory into the bundle resources before launch:
+
+```bash
+cp /path/to/runtime.sqlite3 NmapUIMenuBar.app/Contents/Resources/data/runtime.sqlite3
+```
+
+The current repository does not include the old installer flow referenced in earlier notes.
+
## Usage
-### Start the server
+### Start the app
```bash
sudo npm start
```
-The server runs on port 9000 by default. Access at http://localhost:9000
+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.
+
+### Nightly Eval
+
+```bash
+npm run nightly-eval
+```
+
+This runs the nightly product evaluation loop, which boots the 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
+npm run nightly-eval:dry-run
+```
+
+To install or remove the macOS scheduler from the repo root, use:
+
+```bash
+npm run nightly-eval:launchd-install
+npm run nightly-eval:launchd-uninstall
+```
### Scans
@@ -73,10 +139,10 @@ HTML and PDF reports are generated after each scan. Reports include:
```
.
-├── server.js # Main application
+├── server.js # Main local runtime
├── install.sh # Dependency installer
├── package.json # Node dependencies
-├── google_drive.py # Google Drive sync helper
+├── google_drive_bridge.js # Google Drive helper dispatch layer
├── nmap-modern.xsl # Report stylesheet
├── config.json # App configuration
├── history.json # Scan history
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/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/swiftui-migration-plan.md b/docs/notes/swiftui-migration-plan.md
new file mode 100644
index 00000000..d58eadf2
--- /dev/null
+++ b/docs/notes/swiftui-migration-plan.md
@@ -0,0 +1,65 @@
+# 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.
+
+## 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, and browser auto-open.
+2. Move preferences and menu bar state into the Swift shell without changing scan behavior.
+3. Replace any remaining legacy helper scripts only after the shell and startup flow are stable.
+4. 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.
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/install.sh b/install.sh
index 98353c52..6ec4a2c5 100755
--- a/install.sh
+++ b/install.sh
@@ -159,7 +159,6 @@ verify_commands=(
"node:node"
"npm:npm"
"nmap:nmap"
- "python3:python3"
"xsltproc:xsltproc"
"express:node"
)
diff --git a/package.json b/package.json
index 4f693170..10b30f75 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,12 @@
"main": "server.js",
"scripts": {
"prestart": "node scripts/ensure-dependencies.js",
- "start": "node server.js"
+ "start": "node server.js",
+ "nightly-eval": "./scripts/nightly_product_eval.sh --run",
+ "nightly-eval:run": "./scripts/nightly_product_eval.sh --run",
+ "nightly-eval:dry-run": "./scripts/nightly_product_eval.sh --dry-run",
+ "nightly-eval:launchd-install": "./packaging/macos/nightly_product_eval_launchd.sh install",
+ "nightly-eval:launchd-uninstall": "./packaging/macos/nightly_product_eval_launchd.sh uninstall"
},
"dependencies": {
"axios": "^1.3.4",
diff --git a/packaging/macos/Package.swift b/packaging/macos/Package.swift
new file mode 100644
index 00000000..a139524f
--- /dev/null
+++ b/packaging/macos/Package.swift
@@ -0,0 +1,29 @@
+// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "NmapUI",
+ platforms: [
+ .macOS(.v13)
+ ],
+ products: [
+ .executable(
+ name: "NmapUI",
+ targets: ["NmapUIApp"]
+ ),
+ .executable(
+ name: "GoogleDriveHelper",
+ targets: ["GoogleDriveHelper"]
+ )
+ ],
+ targets: [
+ .executableTarget(
+ name: "NmapUIApp",
+ path: "Sources/NmapUIApp"
+ ),
+ .executableTarget(
+ name: "GoogleDriveHelper",
+ path: "Sources/GoogleDriveHelper"
+ )
+ ]
+)
diff --git a/packaging/macos/README.md b/packaging/macos/README.md
new file mode 100644
index 00000000..c3983952
--- /dev/null
+++ b/packaging/macos/README.md
@@ -0,0 +1,86 @@
+# macOS Native Shell
+
+This directory packages the macOS menu bar shell that launches the local NmapUI runtime.
+
+## Goal
+
+Launch the packaged backend, present a menu bar app, and keep the web UI pinned to port `9000`.
+
+## Suggested Shape
+
+- `Sources/NmapUIApp/` for the Swift app code
+- `Resources/` for the app icon, Info.plist, and menu assets
+- `build.sh` or Xcode project configuration for bundling and codesigning
+
+## Build
+
+```bash
+cd packaging/macos
+./build.sh
+```
+
+Or directly with SwiftPM:
+
+```bash
+cd packaging/macos
+swift build
+```
+
+To assemble a launchable `.app` bundle:
+
+```bash
+cd packaging/macos
+./bundle.sh
+open build/NmapUI.app
+```
+
+To install the bundle into Applications:
+
+```bash
+cd packaging/macos
+./install.sh
+```
+
+To install the nightly product evaluation loop as a LaunchAgent:
+
+```bash
+cd packaging/macos
+./nightly_product_eval_launchd.sh install
+```
+
+To inspect or remove it later:
+
+```bash
+cd packaging/macos
+./nightly_product_eval_launchd.sh status
+./nightly_product_eval_launchd.sh uninstall
+```
+
+## Run
+
+```bash
+cd packaging/macos
+./run.sh
+```
+
+The app is designed to present a menu bar icon, wait for the readiness endpoint, and then open the loopback UI at `http://127.0.0.1:9000`. If startup takes too long, it shows a native prompt, and if the runtime exits unexpectedly, the app automatically attempts one restart before surfacing a native alert so the backend failure is visible right away.
+The bundle uses the existing `static/techmore.png` asset to generate a macOS icon at build time when `sips` and `iconutil` are available.
+The menu bar icon includes a `Runtime:` status row plus a `Preferences...` item for editing the runtime command and data directory from inside the app, plus `Restart Runtime` and `Open Data Folder` actions for quicker recovery and jumping straight to the mutable data location. The status row shows `Starting`, `Ready`, `Restarting`, or `Error` depending on backend state. The data directory uses a native folder picker, can be revealed in Finder, and the panel includes a `Reset to Defaults` action.
+
+## Runtime Overrides
+
+- `NMAPUI_RUNTIME_COMMAND` sets the command used by the launcher, defaulting to `node server.js`.
+- `NMAPUI_DATA_DIR` sets the runtime data directory, defaulting to `~/Library/Application Support/NmapUI`.
+- `NMAPUI_APPLICATIONS_DIR` sets the destination for `./install.sh`, defaulting to `~/Applications`.
+- The nightly eval LaunchAgent installs to `~/Library/LaunchAgents/` and runs `scripts/nightly_product_eval.sh --run`.
+- In-app Preferences persist the launcher overrides in `UserDefaults`, so they survive relaunches. The data directory is selected with a folder picker, can be revealed in Finder, can be reset to defaults, and is also reachable from the `Open Data Folder` menu action. The status bar menu also exposes a runtime status row that reflects startup, ready, restart, and error states, `Restart Runtime` to bounce the backend without opening Preferences first, startup timeouts now offer a single native prompt, and runtime exits get one automatic restart attempt before a native warning prompt with a restart action.
+- `CODESIGN_IDENTITY` signs the app bundle if you have a valid Apple signing identity.
+- `CODESIGN_OPTIONS` overrides the codesign flags, defaulting to `--force --deep --sign`.
+- `OPEN_AFTER_INSTALL` opens the installed app after `./install.sh`, defaulting to `1`.
+
+## Notes
+
+- The backend should stay external, not embedded.
+- The runtime should write its working data into Application Support, not inside the app bundle.
+- The shell should open the local loopback UI once the runtime is healthy.
+- This directory intentionally starts small so the migration can grow without forcing a rewrite all at once.
diff --git a/packaging/macos/Resources/Info.plist b/packaging/macos/Resources/Info.plist
new file mode 100644
index 00000000..ea88e2d0
--- /dev/null
+++ b/packaging/macos/Resources/Info.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ CFBundleName
+ NmapUI
+ CFBundleDisplayName
+ NmapUI
+ CFBundleIdentifier
+ com.techmore.nmapui
+ CFBundleVersion
+ 0.1.0
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundlePackageType
+ APPL
+ CFBundleIconFile
+ AppIcon.icns
+ LSUIElement
+
+
+
diff --git a/packaging/macos/Sources/GoogleDriveHelper/main.swift b/packaging/macos/Sources/GoogleDriveHelper/main.swift
new file mode 100644
index 00000000..16626666
--- /dev/null
+++ b/packaging/macos/Sources/GoogleDriveHelper/main.swift
@@ -0,0 +1,456 @@
+import Foundation
+import CryptoKit
+
+private enum HelperError: Error {
+ case invalidCommand
+ case invalidPayload(String)
+}
+
+private struct TokenState: Codable {
+ var schemaVersion: Int = 1
+ var ciphertext: String?
+ var refreshToken: String?
+ var accessToken: String?
+ var expiresAt: String?
+ var pendingAuth: PendingAuth?
+}
+
+private struct Credentials: Codable {
+ var clientId: String?
+ var clientSecret: String?
+}
+
+private struct PendingAuth: Codable {
+ var state: String
+ var codeVerifier: String
+ var redirectUri: String
+ var createdAt: String
+}
+
+private struct HelperResponse: Codable {
+ var success: Bool
+ var status: String?
+ var error: String?
+ var configured: Bool?
+ var connected: Bool?
+ var expiresAt: String?
+ var authURL: String?
+}
+
+private func readJSON(_ type: T.Type, from path: URL) -> T? {
+ guard let data = try? Data(contentsOf: path) else { return nil }
+ return try? JSONDecoder().decode(T.self, from: data)
+}
+
+private func writeJSON(_ value: T, to path: URL) throws {
+ let data = try JSONEncoder().encode(value)
+ try data.write(to: path, options: [.atomic])
+}
+
+private func defaultPaths(root: URL) -> (credentials: URL, token: URL, key: URL) {
+ let state = root.appendingPathComponent(".google_drive", isDirectory: true)
+ return (
+ state.appendingPathComponent("credentials.json"),
+ state.appendingPathComponent("token.json"),
+ state.appendingPathComponent("token.key")
+ )
+}
+
+private func printJSON(_ value: HelperResponse) {
+ if let data = try? JSONEncoder().encode(value),
+ let jsonObject = try? JSONSerialization.jsonObject(with: data),
+ let pretty = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]),
+ let string = String(data: pretty, encoding: .utf8) {
+ print(string)
+ } else {
+ print("{}")
+ }
+}
+
+private func randomURLSafeToken(byteCount: Int) -> String {
+ var bytes = [UInt8](repeating: 0, count: byteCount)
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ return Data(bytes).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+}
+
+private func sha256URLSafe(_ input: String) -> String {
+ let digest = SHA256.hash(data: Data(input.utf8))
+ return Data(digest).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+}
+
+private struct SimpleResponse {
+ let statusCode: Int
+ let body: Data
+}
+
+private func postForm(url: URL, body: [String: String]) throws -> SimpleResponse {
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ request.httpBody = body
+ .map { "\($0.key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
+ .joined(separator: "&")
+ .data(using: .utf8)
+
+ let semaphore = DispatchSemaphore(value: 0)
+ var output: SimpleResponse?
+ var caught: Error?
+
+ URLSession.shared.dataTask(with: request) { data, response, error in
+ defer { semaphore.signal() }
+ if let error {
+ caught = error
+ return
+ }
+ guard let httpResponse = response as? HTTPURLResponse else {
+ caught = HelperError.invalidPayload("Missing HTTP response")
+ return
+ }
+ output = SimpleResponse(statusCode: httpResponse.statusCode, body: data ?? Data())
+ }.resume()
+
+ semaphore.wait()
+ if let caught { throw caught }
+ guard let output else { throw HelperError.invalidPayload("Missing response body") }
+ return output
+}
+
+private func jsonObject(from data: Data) -> [String: Any] {
+ (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
+}
+
+private func percentEncode(_ value: String) -> String {
+ value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
+}
+
+private func makeMultipartBody(metadata: [String: Any], fileName: String, fileData: Data) -> (body: Data, boundary: String) {
+ let boundary = "----nmapui-\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))"
+ var body = Data()
+ func append(_ string: String) { body.append(Data(string.utf8)) }
+
+ append("--\(boundary)\r\n")
+ append("Content-Disposition: form-data; name=\"metadata\"; filename=\"metadata.json\"\r\n")
+ append("Content-Type: application/json; charset=UTF-8\r\n\r\n")
+ body.append((try? JSONSerialization.data(withJSONObject: metadata)) ?? Data())
+ append("\r\n")
+
+ append("--\(boundary)\r\n")
+ append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n")
+ append("Content-Type: application/octet-stream\r\n\r\n")
+ body.append(fileData)
+ append("\r\n")
+
+ append("--\(boundary)--\r\n")
+ return (body, boundary)
+}
+
+private func formURLEncoded(_ fields: [String: String]) -> Data {
+ fields
+ .map { "\(percentEncode($0.key))=\(percentEncode($0.value))" }
+ .joined(separator: "&")
+ .data(using: .utf8) ?? Data()
+}
+
+private func httpGET(url: String, query: [String: String]? = nil, headers: [String: String] = [:]) throws -> SimpleResponse {
+ var components = URLComponents(string: url)!
+ if let query {
+ components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
+ }
+ var request = URLRequest(url: components.url!)
+ request.httpMethod = "GET"
+ headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
+ let semaphore = DispatchSemaphore(value: 0)
+ var output: SimpleResponse?
+ var caught: Error?
+ URLSession.shared.dataTask(with: request) { data, response, error in
+ defer { semaphore.signal() }
+ if let error {
+ caught = error
+ return
+ }
+ guard let httpResponse = response as? HTTPURLResponse else {
+ caught = HelperError.invalidPayload("Missing HTTP response")
+ return
+ }
+ output = SimpleResponse(statusCode: httpResponse.statusCode, body: data ?? Data())
+ }.resume()
+ semaphore.wait()
+ if let caught { throw caught }
+ guard let output else { throw HelperError.invalidPayload("Missing response body") }
+ return output
+}
+
+private func httpPOST(
+ url: String,
+ headers: [String: String] = [:],
+ body: Data = Data()
+) throws -> SimpleResponse {
+ var request = URLRequest(url: URL(string: url)!)
+ request.httpMethod = "POST"
+ headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
+ request.httpBody = body
+ let semaphore = DispatchSemaphore(value: 0)
+ var output: SimpleResponse?
+ var caught: Error?
+ URLSession.shared.dataTask(with: request) { data, response, error in
+ defer { semaphore.signal() }
+ if let error {
+ caught = error
+ return
+ }
+ guard let httpResponse = response as? HTTPURLResponse else {
+ caught = HelperError.invalidPayload("Missing HTTP response")
+ return
+ }
+ output = SimpleResponse(statusCode: httpResponse.statusCode, body: data ?? Data())
+ }.resume()
+ semaphore.wait()
+ if let caught { throw caught }
+ guard let output else { throw HelperError.invalidPayload("Missing response body") }
+ return output
+}
+
+private func ensureAccessToken(paths: (credentials: URL, token: URL, key: URL)) throws -> String {
+ guard let tokenState = readJSON(TokenState.self, from: paths.token) else {
+ throw HelperError.invalidPayload("Google Drive is not connected.")
+ }
+ if let accessToken = tokenState.accessToken, !accessToken.isEmpty {
+ return accessToken
+ }
+ if let refreshToken = tokenState.refreshToken, !refreshToken.isEmpty {
+ let credentials = readJSON([String: String].self, from: paths.credentials) ?? [:]
+ guard let clientId = credentials["client_id"], let clientSecret = credentials["client_secret"] else {
+ throw HelperError.invalidPayload("Google Drive OAuth credentials file not found. Upload your credentials.json from the Google Cloud Console.")
+ }
+ let response = try httpPOST(url: "https://oauth2.googleapis.com/token", headers: ["Content-Type": "application/x-www-form-urlencoded"], body: formURLEncoded([
+ "client_id": clientId,
+ "client_secret": clientSecret,
+ "refresh_token": refreshToken,
+ "grant_type": "refresh_token",
+ ]))
+ let payload = jsonObject(from: response.body)
+ guard response.statusCode < 400, let refreshed = payload["access_token"] as? String else {
+ throw HelperError.invalidPayload((payload["error_description"] as? String) ?? (payload["error"] as? String) ?? "Failed to refresh Google Drive access token.")
+ }
+ var updated = tokenState
+ updated.accessToken = refreshed
+ updated.expiresAt = ISO8601DateFormatter().string(from: Date().addingTimeInterval(TimeInterval((payload["expires_in"] as? Double).map { Int($0) } ?? 3600)))
+ try writeJSON(updated, to: paths.token)
+ return refreshed
+ }
+ throw HelperError.invalidPayload("Google Drive is not connected.")
+}
+
+private func ensureReportsFolder(
+ paths: (credentials: URL, token: URL, key: URL),
+ folderName: String,
+ parentID: String?
+) throws -> String {
+ let accessToken = try ensureAccessToken(paths: paths)
+ let escaped = folderName.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "'", with: "\\'")
+ var query = "mimeType='application/vnd.google-apps.folder' and name='\(escaped)' and trashed=false"
+ if let parentID {
+ query += " and '\(parentID)' in parents"
+ }
+ let list = try httpGET(url: "https://www.googleapis.com/drive/v3/files", query: ["q": query, "fields": "files(id,name,webViewLink)"], headers: ["Authorization": "Bearer \(accessToken)"])
+ let listPayload = jsonObject(from: list.body)
+ if let files = listPayload["files"] as? [[String: Any]], let first = files.first, let id = first["id"] as? String {
+ return id
+ }
+ var folderPayload: [String: Any] = ["name": folderName, "mimeType": "application/vnd.google-apps.folder"]
+ if let parentID { folderPayload["parents"] = [parentID] }
+ let body = (try? JSONSerialization.data(withJSONObject: folderPayload)) ?? Data()
+ let create = try httpPOST(
+ url: "https://www.googleapis.com/drive/v3/files",
+ headers: [
+ "Authorization": "Bearer \(accessToken)",
+ "Content-Type": "application/json",
+ ],
+ body: body
+ )
+ let createPayload = jsonObject(from: create.body)
+ if let id = createPayload["id"] as? String {
+ return id
+ }
+ throw HelperError.invalidPayload("Failed to create Drive folder.")
+}
+
+private func uploadFile(
+ path: URL,
+ folderID: String,
+ fileNameMap: [String: String],
+ paths: (credentials: URL, token: URL, key: URL)
+) throws -> [String: Any] {
+ let accessToken = try ensureAccessToken(paths: paths)
+ let overrideName = fileNameMap[path.path]
+ let metadata: [String: Any] = {
+ var value: [String: Any] = ["name": overrideName ?? path.lastPathComponent]
+ if !folderID.isEmpty { value["parents"] = [folderID] }
+ return value
+ }()
+ let fileData = try Data(contentsOf: path)
+ let (multipartBody, boundary) = makeMultipartBody(metadata: metadata, fileName: path.lastPathComponent, fileData: fileData)
+ let response = try httpPOST(
+ url: "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,webViewLink",
+ headers: [
+ "Authorization": "Bearer \(accessToken)",
+ "Content-Type": "multipart/form-data; boundary=\(boundary)",
+ ],
+ body: multipartBody
+ )
+ let payload = jsonObject(from: response.body)
+ if response.statusCode >= 400 {
+ throw HelperError.invalidPayload((payload["error"] as? [String: Any])?["message"] as? String ?? "Drive upload failed.")
+ }
+ return payload
+}
+
+@main
+struct GoogleDriveHelper {
+ static func main() {
+ var args = CommandLine.arguments.dropFirst()
+ guard let command = args.first else {
+ printJSON(HelperResponse(success: false, status: nil, error: "Missing command", configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(1)
+ }
+ args = args.dropFirst()
+
+ var root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
+ var redirectURI = ""
+ var credentialsJSON = ""
+ var code = ""
+ var state = ""
+
+ var i = 0
+ while i < args.count {
+ let arg = args[args.index(args.startIndex, offsetBy: i)]
+ switch arg {
+ case "--root":
+ i += 1
+ root = URL(fileURLWithPath: String(args[args.index(args.startIndex, offsetBy: i)]))
+ case "--redirect-uri":
+ i += 1
+ redirectURI = String(args[args.index(args.startIndex, offsetBy: i)])
+ case "--credentials-json":
+ i += 1
+ credentialsJSON = String(args[args.index(args.startIndex, offsetBy: i)])
+ case "--code":
+ i += 1
+ code = String(args[args.index(args.startIndex, offsetBy: i)])
+ case "--state":
+ i += 1
+ state = String(args[args.index(args.startIndex, offsetBy: i)])
+ default:
+ break
+ }
+ i += 1
+ }
+
+ let paths = defaultPaths(root: root)
+
+ do {
+ switch command {
+ case "status":
+ let credentials = readJSON([String: String].self, from: paths.credentials) ?? [:]
+ let tokenState = readJSON(TokenState.self, from: paths.token)
+ let configured = (credentials["client_id"]?.isEmpty == false) && (credentials["client_secret"]?.isEmpty == false)
+ let connected = (tokenState?.refreshToken?.isEmpty == false) || (tokenState?.accessToken?.isEmpty == false)
+ let status: String = connected ? "Connected" : (configured ? "Not connected" : "OAuth credentials missing. Import credentials.json to enable Google Drive.")
+ printJSON(HelperResponse(success: true, status: status, error: nil, configured: configured, connected: connected, expiresAt: tokenState?.expiresAt, authURL: nil))
+ exit(0)
+
+ case "save-credentials":
+ let payloadData = credentialsJSON.isEmpty ? Data("{}".utf8) : Data(credentialsJSON.utf8)
+ guard let _ = try? JSONSerialization.jsonObject(with: payloadData) else {
+ throw HelperError.invalidPayload("Invalid credentials payload")
+ }
+ try FileManager.default.createDirectory(at: paths.credentials.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try payloadData.write(to: paths.credentials, options: [.atomic])
+ printJSON(HelperResponse(success: true, status: "Google Drive credentials saved", error: nil, configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(0)
+
+ case "auth-url":
+ let credentials = readJSON([String: String].self, from: paths.credentials) ?? [:]
+ guard let clientId = credentials["client_id"], !clientId.isEmpty,
+ let _ = credentials["client_secret"], !redirectURI.isEmpty else {
+ printJSON(HelperResponse(success: false, status: nil, error: "Google Drive OAuth credentials file not found. Upload your credentials.json from the Google Cloud Console.", configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(1)
+ }
+ let stateToken = randomURLSafeToken(byteCount: 24)
+ let verifier = randomURLSafeToken(byteCount: 48)
+ let challenge = sha256URLSafe(verifier)
+ let pending = PendingAuth(state: stateToken, codeVerifier: verifier, redirectUri: redirectURI, createdAt: ISO8601DateFormatter().string(from: Date()))
+ try writeJSON(TokenState(schemaVersion: 1, ciphertext: nil, refreshToken: nil, accessToken: nil, expiresAt: nil, pendingAuth: pending), to: paths.token)
+ let authURL = "https://accounts.google.com/o/oauth2/v2/auth?client_id=\(clientId)&redirect_uri=\(redirectURI)&response_type=code&scope=https://www.googleapis.com/auth/drive.file&access_type=offline&prompt=consent&include_granted_scopes=true&state=\(stateToken)&code_challenge=\(challenge)&code_challenge_method=S256"
+ printJSON(HelperResponse(success: true, status: nil, error: nil, configured: nil, connected: nil, expiresAt: nil, authURL: authURL))
+ exit(0)
+
+ case "disconnect":
+ try? FileManager.default.removeItem(at: paths.token)
+ try? FileManager.default.removeItem(at: paths.key)
+ printJSON(HelperResponse(success: true, status: "Google Drive disconnected", error: nil, configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(0)
+
+ case "exchange-code":
+ let tokenState = readJSON(TokenState.self, from: paths.token)
+ guard let pending = tokenState?.pendingAuth else {
+ printJSON(HelperResponse(success: false, status: nil, error: "Invalid or expired Google Drive auth state.", configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(1)
+ }
+ guard pending.state == state else {
+ printJSON(HelperResponse(success: false, status: nil, error: "Invalid or expired Google Drive auth state.", configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(1)
+ }
+ let credentials = readJSON([String: String].self, from: paths.credentials) ?? [:]
+ guard let clientId = credentials["client_id"], let clientSecret = credentials["client_secret"] else {
+ printJSON(HelperResponse(success: false, status: nil, error: "Google Drive OAuth credentials file not found. Upload your credentials.json from the Google Cloud Console.", configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(1)
+ }
+ let response = try postForm(url: URL(string: "https://oauth2.googleapis.com/token")!, body: [
+ "client_id": clientId,
+ "client_secret": clientSecret,
+ "code": code,
+ "code_verifier": pending.codeVerifier,
+ "grant_type": "authorization_code",
+ "redirect_uri": pending.redirectUri,
+ ])
+ let payload = jsonObject(from: response.body)
+ if response.statusCode >= 400 || payload["access_token"] == nil {
+ printJSON(HelperResponse(success: false, status: nil, error: (payload["error_description"] as? String) ?? (payload["error"] as? String) ?? "Failed to exchange Google Drive auth code.", configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(1)
+ }
+ let expiresIn = (payload["expires_in"] as? Double).map { Int($0) } ?? 3600
+ var updatedState = TokenState(schemaVersion: 1, ciphertext: nil, refreshToken: tokenState?.refreshToken ?? (payload["refresh_token"] as? String), accessToken: payload["access_token"] as? String, expiresAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(TimeInterval(expiresIn))), pendingAuth: nil)
+ try writeJSON(updatedState, to: paths.token)
+ printJSON(HelperResponse(success: true, status: "Google Drive connected", error: nil, configured: nil, connected: nil, expiresAt: updatedState.expiresAt, authURL: nil))
+ exit(0)
+
+ case "upload":
+ let filePaths = args
+ .filter { $0.hasPrefix("/") }
+ .map { URL(fileURLWithPath: $0) }
+ let folderName = "Nmap Reports"
+ let folderID = try ensureReportsFolder(paths: paths, folderName: folderName, parentID: nil)
+ var uploaded: [[String: Any]] = []
+ for fileURL in filePaths {
+ let result = try uploadFile(path: fileURL, folderID: folderID, fileNameMap: [:], paths: paths)
+ uploaded.append(result)
+ }
+ printJSON(HelperResponse(success: true, status: "Uploaded \(uploaded.count) file(s) to Google Drive", error: nil, configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(0)
+
+ default:
+ throw HelperError.invalidCommand
+ }
+ } catch {
+ printJSON(HelperResponse(success: false, status: nil, error: String(describing: error), configured: nil, connected: nil, expiresAt: nil, authURL: nil))
+ exit(1)
+ }
+ }
+}
diff --git a/packaging/macos/Sources/NmapUIApp/AppDelegate.swift b/packaging/macos/Sources/NmapUIApp/AppDelegate.swift
new file mode 100644
index 00000000..7b158c7a
--- /dev/null
+++ b/packaging/macos/Sources/NmapUIApp/AppDelegate.swift
@@ -0,0 +1,399 @@
+import AppKit
+import Foundation
+import ServiceManagement
+import SwiftUI
+
+@MainActor
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ private let runtimeURL = RuntimeEndpoints.baseURL
+ private let processLauncher = ProcessLauncher()
+ private lazy var startupCoordinator = StartupCoordinator(readinessURL: RuntimeEndpoints.readinessURL, runtimeURL: runtimeURL)
+
+ private var statusItem: NSStatusItem?
+ private var runtimeStatusMenuItem: NSMenuItem?
+ private var openAppMenuItem: NSMenuItem?
+ private var openDataDirectoryMenuItem: NSMenuItem?
+ private var restartRuntimeMenuItem: NSMenuItem?
+ private var launchAtLoginMenuItem: NSMenuItem?
+
+ private var runtimeProcess: Process?
+ private var runtimeStopRequested = false
+ private var runtimeAutoRestartAttempted = false
+ private var runtimeStatusText = "Starting..."
+ private var runtimeIsReady = false
+ let preferencesStore = PreferencesStore()
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ setupStatusItem()
+ syncLaunchAtLoginState()
+ runtimeProcess = processLauncher.launchRuntimeIfNeeded()
+ if runtimeProcess == nil {
+ runtimeStatusText = "Error"
+ syncRuntimeMenuState()
+ presentRuntimeLaunchFailureAlert()
+ } else {
+ attachRuntimeTerminationHandler()
+ }
+ beginRuntimeStartupWatch()
+ }
+
+ private func setupStatusItem() {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ guard let button = statusItem?.button else { return }
+ updateStatusIcon(isReady: false)
+ button.imagePosition = .imageOnly
+ button.toolTip = "NmapUI"
+
+ let menu = NSMenu()
+ let runtimeStatusItem = NSMenuItem(title: "Runtime: Starting...", action: nil, keyEquivalent: "")
+ runtimeStatusItem.isEnabled = false
+ menu.addItem(runtimeStatusItem)
+ runtimeStatusMenuItem = runtimeStatusItem
+ menu.addItem(.separator())
+
+ let openItem = NSMenuItem(title: "Starting NmapUI...", action: #selector(openApp), keyEquivalent: "o")
+ openItem.target = self
+ openItem.isEnabled = false
+ menu.addItem(openItem)
+ openAppMenuItem = openItem
+ menu.addItem(.separator())
+
+ let preferencesItem = NSMenuItem(title: "Preferences...", action: #selector(openPreferences), keyEquivalent: ",")
+ preferencesItem.target = self
+ menu.addItem(preferencesItem)
+ menu.addItem(.separator())
+
+ let restartItem = NSMenuItem(title: "Restart Runtime", action: #selector(restartRuntime), keyEquivalent: "r")
+ restartItem.target = self
+ menu.addItem(restartItem)
+ restartRuntimeMenuItem = restartItem
+ menu.addItem(.separator())
+
+ let dataDirectoryItem = NSMenuItem(title: "Open Data Folder", action: #selector(openDataDirectory), keyEquivalent: "")
+ dataDirectoryItem.target = self
+ menu.addItem(dataDirectoryItem)
+ openDataDirectoryMenuItem = dataDirectoryItem
+ menu.addItem(.separator())
+
+ let loginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "")
+ loginItem.target = self
+ menu.addItem(loginItem)
+ launchAtLoginMenuItem = loginItem
+ menu.addItem(.separator())
+
+ let uninstallItem = NSMenuItem(title: "Uninstall NmapUI", action: #selector(uninstallApp), keyEquivalent: "")
+ uninstallItem.target = self
+ menu.addItem(uninstallItem)
+
+ menu.addItem(withTitle: "Quit", action: #selector(quitApp), keyEquivalent: "q")
+ statusItem?.menu = menu
+ }
+
+ private func syncLaunchAtLoginState() {
+ guard #available(macOS 13.0, *) else {
+ launchAtLoginMenuItem?.isEnabled = false
+ launchAtLoginMenuItem?.state = .off
+ return
+ }
+ launchAtLoginMenuItem?.state = SMAppService.mainApp.status == .enabled ? .on : .off
+ }
+
+ @objc private func openApp() {
+ NSWorkspace.shared.open(runtimeURL)
+ }
+
+ @objc private func openPreferences() {
+ NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ @objc private func openDataDirectory() {
+ NSWorkspace.shared.activateFileViewerSelecting([ProcessLauncher.currentDataDirectoryURL()])
+ }
+
+ func openDataDirectoryFromSwiftUI() {
+ openDataDirectory()
+ }
+
+ @objc private func restartRuntime() {
+ restartRuntimeAfterPreferenceChange()
+ }
+
+ func savePreferencesFromSwiftUI() {
+ savePreferences()
+ }
+
+ func resetPreferencesFromSwiftUI() {
+ resetPreferences()
+ }
+
+ @objc private func savePreferences() {
+ preferencesStore.save()
+ restartRuntimeAfterPreferenceChange()
+ }
+
+ @objc private func resetPreferences() {
+ preferencesStore.clearPersistedValues()
+ preferencesStore.resetToDefaults()
+ restartRuntimeAfterPreferenceChange()
+ }
+
+ private func beginRuntimeStartupWatch() {
+ startupCoordinator.begin()
+ Task { [weak self] in
+ guard let self else { return }
+ let result = await self.startupCoordinator.observeStartup()
+ guard let result else { return }
+ switch result {
+ case .ready:
+ self.runtimeIsReady = true
+ self.runtimeStatusText = "Ready"
+ self.runtimeAutoRestartAttempted = false
+ self.syncRuntimeMenuState()
+ NSWorkspace.shared.open(self.runtimeURL)
+ case .timeout:
+ self.runtimeIsReady = false
+ self.runtimeStatusText = "Starting..."
+ self.syncRuntimeMenuState()
+ self.presentRuntimeStartupTimeoutAlert()
+ }
+ }
+ }
+
+ @objc private func toggleLaunchAtLogin() {
+ guard #available(macOS 13.0, *) else { return }
+ do {
+ if SMAppService.mainApp.status == .enabled {
+ try SMAppService.mainApp.unregister()
+ } else {
+ try SMAppService.mainApp.register()
+ }
+ syncLaunchAtLoginState()
+ } catch {
+ NSLog("Failed to toggle launch at login: \(error.localizedDescription)")
+ }
+ }
+
+ @objc private func uninstallApp() {
+ runtimeStopRequested = true
+ stopRuntimeProcess()
+ if #available(macOS 13.0, *) {
+ try? SMAppService.mainApp.unregister()
+ }
+ let bundleURL = Bundle.main.bundleURL
+ NSWorkspace.shared.recycle([bundleURL]) { _, _ in
+ DispatchQueue.main.async { NSApp.terminate(nil) }
+ }
+ }
+
+ @objc private func quitApp() {
+ runtimeStopRequested = true
+ stopRuntimeProcess()
+ NSApp.terminate(nil)
+ }
+
+ private func stopRuntimeProcess() {
+ processLauncher.stop(runtimeProcess: runtimeProcess)
+ runtimeProcess = nil
+ }
+
+ private func restartRuntimeAfterPreferenceChange() {
+ runtimeStopRequested = true
+ stopRuntimeProcess()
+ runtimeIsReady = false
+ runtimeAutoRestartAttempted = false
+ runtimeStatusText = "Starting..."
+ syncRuntimeMenuState()
+
+ runtimeProcess = processLauncher.launchRuntimeIfNeeded()
+ if runtimeProcess == nil {
+ runtimeStatusText = "Error"
+ syncRuntimeMenuState()
+ presentRuntimeLaunchFailureAlert()
+ } else {
+ attachRuntimeTerminationHandler()
+ }
+
+ runtimeStopRequested = false
+ beginRuntimeStartupWatch()
+ }
+
+ private func attachRuntimeTerminationHandler() {
+ runtimeProcess?.terminationHandler = { [weak self] process in
+ Task { @MainActor in
+ self?.handleRuntimeExit(terminationStatus: process.terminationStatus)
+ }
+ }
+ }
+
+ private func handleRuntimeExit(terminationStatus: Int32) {
+ runtimeProcess = nil
+ runtimeIsReady = false
+ runtimeStatusText = "Error"
+ syncRuntimeMenuState()
+
+ guard !runtimeStopRequested else { return }
+
+ if !runtimeAutoRestartAttempted {
+ runtimeAutoRestartAttempted = true
+ runtimeStatusText = "Restarting..."
+ syncRuntimeMenuState()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
+ self?.restartRuntimeAfterPreferenceChange()
+ }
+ return
+ }
+
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = "NmapUI runtime stopped"
+ alert.informativeText = "The backend exited with status \(terminationStatus). Restart the runtime from the menu if you want another attempt."
+ alert.addButton(withTitle: "Restart Runtime")
+ alert.addButton(withTitle: "OK")
+
+ if alert.runModal() == .alertFirstButtonReturn {
+ restartRuntimeAfterPreferenceChange()
+ }
+ }
+
+ private func presentRuntimeLaunchFailureAlert() {
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = "NmapUI could not start the runtime"
+ alert.informativeText = "Check the runtime command and make sure the app can bind the fixed port."
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ }
+
+ private func presentRuntimeStartupTimeoutAlert() {
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = "NmapUI is still starting"
+ alert.informativeText = "The runtime did not become ready in time. Keep the app open and use Restart Runtime from the menu if you need another startup attempt."
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ }
+
+ private func syncRuntimeMenuState() {
+ runtimeStatusMenuItem?.title = "Runtime: \(runtimeStatusText)"
+ openAppMenuItem?.title = runtimeIsReady ? "Open App" : "Starting NmapUI..."
+ openAppMenuItem?.isEnabled = runtimeIsReady
+ restartRuntimeMenuItem?.isEnabled = true
+ openDataDirectoryMenuItem?.isEnabled = true
+ updateStatusIcon(isReady: runtimeIsReady)
+ }
+
+ private func updateStatusIcon(isReady: Bool) {
+ guard let button = statusItem?.button else { return }
+ let symbolName = isReady ? "network" : "hourglass"
+ let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: "NmapUI")
+ image?.isTemplate = true
+ button.image = image
+ button.contentTintColor = isReady ? .controlAccentColor : .secondaryLabelColor
+ }
+}
+
+final class ProcessLauncher {
+ private let runtimeCommand: String
+ private let runtimeWorkDir: URL
+ private let runtimeDataDir: URL
+
+ init() {
+ let environment = ProcessInfo.processInfo.environment
+ if let persistedCommand = UserDefaults.standard.string(forKey: PreferencesKeys.runtimeCommand), !persistedCommand.isEmpty {
+ runtimeCommand = persistedCommand
+ } else {
+ runtimeCommand = environment["NMAPUI_RUNTIME_COMMAND"] ?? "node server.js"
+ }
+
+ if let persistedWorkDir = environment["NMAPUI_RUNTIME_WORKDIR"], !persistedWorkDir.isEmpty {
+ runtimeWorkDir = URL(fileURLWithPath: persistedWorkDir)
+ } else {
+ runtimeWorkDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
+ }
+
+ if let persistedDataDir = UserDefaults.standard.string(forKey: PreferencesKeys.dataDirectory), !persistedDataDir.isEmpty {
+ runtimeDataDir = URL(fileURLWithPath: persistedDataDir)
+ } else if let explicitDataDir = environment["NMAPUI_DATA_DIR"], !explicitDataDir.isEmpty {
+ runtimeDataDir = URL(fileURLWithPath: explicitDataDir)
+ } else {
+ runtimeDataDir = Self.defaultDataDirectory()
+ }
+ }
+
+ func launchRuntimeIfNeeded() -> Process? {
+ let process = Process()
+ process.currentDirectoryURL = runtimeWorkDir
+ process.executableURL = URL(fileURLWithPath: "/bin/zsh")
+ process.arguments = ["-lc", runtimeCommand]
+ process.environment = [
+ "HOST": RuntimeEndpoints.host,
+ "PORT": RuntimeEndpoints.fixedPortString,
+ "NMAPUI_PORT": RuntimeEndpoints.fixedPortString,
+ "NMAPUI_RUNTIME_WORKDIR": runtimeWorkDir.path,
+ "NMAPUI_DATA_DIR": runtimeDataDir.path,
+ "PATH": ProcessInfo.processInfo.environment["PATH"] ?? ""
+ ]
+
+ do {
+ try process.run()
+ return process
+ } catch {
+ NSLog("Failed to launch runtime: \(error.localizedDescription)")
+ return nil
+ }
+ }
+
+ func stop(runtimeProcess: Process?) {
+ guard let runtimeProcess else { return }
+ if runtimeProcess.isRunning {
+ runtimeProcess.terminate()
+ }
+ }
+
+ static func waitForRuntime(url: URL, timeout: TimeInterval) async -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ let session = URLSession(configuration: .ephemeral)
+ while Date() < deadline {
+ do {
+ let (_, response) = try await session.data(from: url)
+ if let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) {
+ return true
+ }
+ } catch {
+ // Keep polling until timeout.
+ }
+ try? await Task.sleep(nanoseconds: 500_000_000)
+ }
+ return false
+ }
+
+ private static func defaultDataDirectory() -> URL {
+ let fileManager = FileManager.default
+ let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
+ let dataDir = appSupport.appendingPathComponent("NmapUI", isDirectory: true)
+ try? fileManager.createDirectory(at: dataDir, withIntermediateDirectories: true)
+ return dataDir
+ }
+
+ static func currentRuntimeCommand() -> String {
+ UserDefaults.standard.string(forKey: PreferencesKeys.runtimeCommand) ?? "node server.js"
+ }
+
+ static func currentDataDirectory() -> String {
+ currentDataDirectoryURL().path
+ }
+
+ static func currentDataDirectoryURL() -> URL {
+ if let persistedDataDir = UserDefaults.standard.string(forKey: PreferencesKeys.dataDirectory), !persistedDataDir.isEmpty {
+ return URL(fileURLWithPath: persistedDataDir)
+ }
+ return defaultDataDirectory()
+ }
+
+ static func isLaunchAtLoginEnabled() -> Bool {
+ guard #available(macOS 13.0, *) else { return false }
+ return SMAppService.mainApp.status == .enabled
+ }
+}
diff --git a/packaging/macos/Sources/NmapUIApp/NmapUIApp.swift b/packaging/macos/Sources/NmapUIApp/NmapUIApp.swift
new file mode 100644
index 00000000..b7e19bd1
--- /dev/null
+++ b/packaging/macos/Sources/NmapUIApp/NmapUIApp.swift
@@ -0,0 +1,19 @@
+import AppKit
+import SwiftUI
+
+@main
+struct NmapUIApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+
+ var body: some Scene {
+ Settings {
+ PreferencesView(
+ store: appDelegate.preferencesStore,
+ onChooseFolder: { appDelegate.openDataDirectoryFromSwiftUI() },
+ onRevealFolder: { appDelegate.openDataDirectoryFromSwiftUI() },
+ onSave: { appDelegate.savePreferencesFromSwiftUI() },
+ onReset: { appDelegate.resetPreferencesFromSwiftUI() }
+ )
+ }
+ }
+}
diff --git a/packaging/macos/Sources/NmapUIApp/PreferencesView.swift b/packaging/macos/Sources/NmapUIApp/PreferencesView.swift
new file mode 100644
index 00000000..10b890d2
--- /dev/null
+++ b/packaging/macos/Sources/NmapUIApp/PreferencesView.swift
@@ -0,0 +1,95 @@
+import ServiceManagement
+import SwiftUI
+
+enum PreferencesKeys {
+ static let runtimeCommand = "NMAPUI_RUNTIME_COMMAND"
+ static let dataDirectory = "NMAPUI_DATA_DIR"
+}
+
+@MainActor
+final class PreferencesStore: ObservableObject {
+ @Published var runtimeCommand: String
+ @Published var dataDirectoryPath: String
+ @Published var launchAtLoginEnabled: Bool
+
+ init() {
+ runtimeCommand = ProcessLauncher.currentRuntimeCommand()
+ dataDirectoryPath = ProcessLauncher.currentDataDirectory()
+ launchAtLoginEnabled = ProcessLauncher.isLaunchAtLoginEnabled()
+ }
+
+ func save() {
+ let defaults = UserDefaults.standard
+ defaults.set(runtimeCommand, forKey: PreferencesKeys.runtimeCommand)
+ defaults.set(dataDirectoryPath, forKey: PreferencesKeys.dataDirectory)
+
+ do {
+ if #available(macOS 13.0, *) {
+ if launchAtLoginEnabled {
+ try SMAppService.mainApp.register()
+ } else {
+ try SMAppService.mainApp.unregister()
+ }
+ }
+ } catch {
+ NSLog("Failed to update launch at login from preferences: \(error.localizedDescription)")
+ }
+ }
+
+ func resetToDefaults() {
+ runtimeCommand = ProcessLauncher.currentRuntimeCommand()
+ dataDirectoryPath = ProcessLauncher.currentDataDirectory()
+ launchAtLoginEnabled = ProcessLauncher.isLaunchAtLoginEnabled()
+ }
+
+ func clearPersistedValues() {
+ let defaults = UserDefaults.standard
+ defaults.removeObject(forKey: PreferencesKeys.runtimeCommand)
+ defaults.removeObject(forKey: PreferencesKeys.dataDirectory)
+ }
+}
+
+struct PreferencesView: View {
+ @ObservedObject var store: PreferencesStore
+ let onChooseFolder: () -> Void
+ let onRevealFolder: () -> Void
+ let onSave: () -> Void
+ let onReset: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Runtime Command")
+ .font(.headline)
+ TextField("node server.js", text: $store.runtimeCommand)
+ .textFieldStyle(.roundedBorder)
+ }
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Data Directory")
+ .font(.headline)
+ HStack(spacing: 10) {
+ TextField("", text: $store.dataDirectoryPath)
+ .textFieldStyle(.roundedBorder)
+ Button("Choose Folder…", action: onChooseFolder)
+ Button("Reveal in Finder", action: onRevealFolder)
+ }
+ }
+
+ Toggle("Launch at Login", isOn: $store.launchAtLoginEnabled)
+
+ HStack {
+ Button("Reset to Defaults") {
+ onReset()
+ }
+ Spacer()
+ Button("Save") {
+ onSave()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(20)
+ .frame(width: 540)
+ }
+}
diff --git a/packaging/macos/Sources/NmapUIApp/Resources/Info.plist b/packaging/macos/Sources/NmapUIApp/Resources/Info.plist
new file mode 100644
index 00000000..2e32461e
--- /dev/null
+++ b/packaging/macos/Sources/NmapUIApp/Resources/Info.plist
@@ -0,0 +1,18 @@
+
+
+
+
+ CFBundleName
+ NmapUI
+ CFBundleDisplayName
+ NmapUI
+ CFBundleIdentifier
+ com.techmore.nmapui
+ CFBundleVersion
+ 0.1.0
+ CFBundleShortVersionString
+ 0.1.0
+ LSUIElement
+
+
+
diff --git a/packaging/macos/Sources/NmapUIApp/RuntimeEndpoints.swift b/packaging/macos/Sources/NmapUIApp/RuntimeEndpoints.swift
new file mode 100644
index 00000000..fe55170b
--- /dev/null
+++ b/packaging/macos/Sources/NmapUIApp/RuntimeEndpoints.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+enum RuntimeEndpoints {
+ static let host = "127.0.0.1"
+ static let port = 9000
+
+ static var baseURL: URL {
+ URL(string: "http://\(host):\(port)")!
+ }
+
+ static var readinessURL: URL {
+ baseURL.appendingPathComponent("api/health/ready")
+ }
+
+ static var fixedPortString: String {
+ String(port)
+ }
+}
diff --git a/packaging/macos/Sources/NmapUIApp/StartupCoordinator.swift b/packaging/macos/Sources/NmapUIApp/StartupCoordinator.swift
new file mode 100644
index 00000000..115b82c5
--- /dev/null
+++ b/packaging/macos/Sources/NmapUIApp/StartupCoordinator.swift
@@ -0,0 +1,38 @@
+import AppKit
+import Foundation
+
+@MainActor
+final class StartupCoordinator {
+ enum Result {
+ case ready
+ case timeout
+ }
+
+ private let readinessURL: URL
+ private let runtimeURL: URL
+ private var launchToken = UUID()
+ private var startupTimeoutPrompted = false
+
+ init(readinessURL: URL, runtimeURL: URL) {
+ self.readinessURL = readinessURL
+ self.runtimeURL = runtimeURL
+ }
+
+ func begin() {
+ launchToken = UUID()
+ startupTimeoutPrompted = false
+ }
+
+ func observeStartup() async -> Result? {
+ let token = launchToken
+ let isReady = await ProcessLauncher.waitForRuntime(url: readinessURL, timeout: 20)
+ guard launchToken == token else { return nil }
+ if isReady {
+ startupTimeoutPrompted = false
+ return .ready
+ }
+ guard !startupTimeoutPrompted else { return nil }
+ startupTimeoutPrompted = true
+ return .timeout
+ }
+}
diff --git a/packaging/macos/build.sh b/packaging/macos/build.sh
new file mode 100755
index 00000000..39af58a7
--- /dev/null
+++ b/packaging/macos/build.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+swift build
diff --git a/packaging/macos/bundle.sh b/packaging/macos/bundle.sh
new file mode 100755
index 00000000..0ae22346
--- /dev/null
+++ b/packaging/macos/bundle.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+APP_NAME="${APP_NAME:-NmapUI}"
+BUILD_DIR="${BUILD_DIR:-$SCRIPT_DIR/.build}"
+PRODUCT_PATH="$BUILD_DIR/debug/$APP_NAME"
+APP_BUNDLE="${APP_BUNDLE:-$SCRIPT_DIR/build/$APP_NAME.app}"
+RESOURCES_DIR="$APP_BUNDLE/Contents/Resources"
+MACOS_DIR="$APP_BUNDLE/Contents/MacOS"
+ICONSET_DIR="$SCRIPT_DIR/build/AppIcon.iconset"
+ICON_FILE="$RESOURCES_DIR/AppIcon.icns"
+GENERATED_ASSETS_DIR="$SCRIPT_DIR/build/generated-assets"
+SIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
+SIGN_OPTIONS="${CODESIGN_OPTIONS:---force --deep --sign}"
+
+cd "$SCRIPT_DIR"
+swift build
+
+rm -rf "$APP_BUNDLE"
+rm -rf "$ICONSET_DIR"
+rm -rf "$GENERATED_ASSETS_DIR"
+mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" "$GENERATED_ASSETS_DIR"
+
+cp "$PRODUCT_PATH" "$MACOS_DIR/$APP_NAME"
+cp "$SCRIPT_DIR/Resources/Info.plist" "$APP_BUNDLE/Contents/Info.plist"
+chmod +x "$MACOS_DIR/$APP_NAME"
+
+if command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then
+ mkdir -p "$ICONSET_DIR"
+ for size in 16 32 64 128 256 512; do
+ sips -z "$size" "$size" "$SCRIPT_DIR/../../static/techmore.png" --out "$GENERATED_ASSETS_DIR/icon_${size}x${size}.png" >/dev/null
+ done
+ cp "$SCRIPT_DIR/../../static/techmore.png" "$GENERATED_ASSETS_DIR/icon_512x512@2x.png"
+ cp "$GENERATED_ASSETS_DIR"/icon_*.png "$ICONSET_DIR/"
+ iconutil -c icns "$ICONSET_DIR" -o "$ICON_FILE"
+fi
+
+if [ -n "$SIGN_IDENTITY" ]; then
+ codesign $SIGN_OPTIONS "$SIGN_IDENTITY" "$APP_BUNDLE"
+fi
+
+echo "Created $APP_BUNDLE"
diff --git a/packaging/macos/com.nmapui.nightly-product-eval.plist b/packaging/macos/com.nmapui.nightly-product-eval.plist
new file mode 100644
index 00000000..3e71d80c
--- /dev/null
+++ b/packaging/macos/com.nmapui.nightly-product-eval.plist
@@ -0,0 +1,37 @@
+
+
+
+
+ Label
+ com.nmapui.nightly-product-eval
+
+ ProgramArguments
+
+ /Users/seandolbec/Projects/NmapUI/scripts/nightly_product_eval.sh
+ --run
+
+
+ WorkingDirectory
+ /Users/seandolbec/Projects/NmapUI
+
+ StartCalendarInterval
+
+ Hour
+ 2
+ Minute
+ 0
+
+
+ StandardOutPath
+ /Users/seandolbec/Projects/NmapUI/docs/notes/eval-logs/nightly-product-eval.launchd.out.log
+
+ StandardErrorPath
+ /Users/seandolbec/Projects/NmapUI/docs/notes/eval-logs/nightly-product-eval.launchd.err.log
+
+ RunAtLoad
+
+
+ KeepAlive
+
+
+
diff --git a/packaging/macos/install.sh b/packaging/macos/install.sh
new file mode 100755
index 00000000..5801d2eb
--- /dev/null
+++ b/packaging/macos/install.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+APP_NAME="${APP_NAME:-NmapUI}"
+APP_BUNDLE="${APP_BUNDLE:-$SCRIPT_DIR/build/$APP_NAME.app}"
+DEST_DIR="${NMAPUI_APPLICATIONS_DIR:-${HOME}/Applications}"
+DEST_BUNDLE="$DEST_DIR/$APP_NAME.app"
+SIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
+OPEN_AFTER_INSTALL="${OPEN_AFTER_INSTALL:-1}"
+
+cd "$SCRIPT_DIR"
+./bundle.sh
+
+mkdir -p "$DEST_DIR"
+rm -rf "$DEST_BUNDLE"
+cp -R "$APP_BUNDLE" "$DEST_DIR/"
+
+if [ -n "$SIGN_IDENTITY" ]; then
+ codesign --force --deep --sign "$SIGN_IDENTITY" "$DEST_BUNDLE"
+fi
+
+echo "Installed $DEST_BUNDLE"
+
+if [ "$OPEN_AFTER_INSTALL" != "0" ]; then
+ open "$DEST_BUNDLE"
+fi
diff --git a/packaging/macos/nightly_product_eval_launchd.sh b/packaging/macos/nightly_product_eval_launchd.sh
new file mode 100755
index 00000000..d0d545b4
--- /dev/null
+++ b/packaging/macos/nightly_product_eval_launchd.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PLIST_NAME="com.nmapui.nightly-product-eval.plist"
+PLIST_SOURCE="$SCRIPT_DIR/$PLIST_NAME"
+PLIST_DEST="${HOME}/Library/LaunchAgents/$PLIST_NAME"
+ACTION="${1:-install}"
+
+install_plist() {
+ mkdir -p "$(dirname "$PLIST_DEST")"
+ cp "$PLIST_SOURCE" "$PLIST_DEST"
+ launchctl bootout "gui/$UID" "$PLIST_DEST" >/dev/null 2>&1 || true
+ launchctl bootstrap "gui/$UID" "$PLIST_DEST"
+ launchctl enable "gui/$UID/$PLIST_NAME" >/dev/null 2>&1 || true
+ echo "Installed $PLIST_DEST"
+}
+
+uninstall_plist() {
+ launchctl bootout "gui/$UID" "$PLIST_DEST" >/dev/null 2>&1 || true
+ rm -f "$PLIST_DEST"
+ echo "Removed $PLIST_DEST"
+}
+
+case "$ACTION" in
+ install)
+ install_plist
+ ;;
+ uninstall)
+ uninstall_plist
+ ;;
+ status)
+ launchctl print "gui/$UID/$PLIST_NAME" || true
+ ;;
+ *)
+ echo "Usage: $0 [install|uninstall|status]" >&2
+ exit 1
+ ;;
+esac
diff --git a/packaging/macos/run.sh b/packaging/macos/run.sh
new file mode 100755
index 00000000..48a67868
--- /dev/null
+++ b/packaging/macos/run.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+export NMAPUI_DATA_DIR="${NMAPUI_DATA_DIR:-$HOME/Library/Application Support/NmapUI}"
+export NMAPUI_RUNTIME_WORKDIR="${NMAPUI_RUNTIME_WORKDIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
+export PORT="${PORT:-9000}"
+export HOST="${HOST:-127.0.0.1}"
+
+swift run
diff --git a/scripts/nightly_product_eval.sh b/scripts/nightly_product_eval.sh
new file mode 100755
index 00000000..5d0045d9
--- /dev/null
+++ b/scripts/nightly_product_eval.sh
@@ -0,0 +1,301 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ROOT_DIR="$BASE_DIR"
+LOG_DIR="${NMAPUI_EVAL_LOG_DIR:-$ROOT_DIR/docs/notes/eval-logs}"
+DEFAULT_PORT="${NMAPUI_EVAL_PORT:-0}"
+MODE="${1:---dry-run}"
+SAFE_TARGET="${NMAPUI_EVAL_TARGET:-127.0.0.1}"
+
+timestamp() {
+ node -e 'process.stdout.write(new Date().toISOString())'
+}
+
+git_revision() {
+ git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || printf '%s' unknown
+}
+
+server_log() {
+ printf '%s/nightly-product-eval-server.log' "$LOG_DIR"
+}
+
+run_log() {
+ printf '%s/nightly-product-eval.log' "$LOG_DIR"
+}
+
+json_log() {
+ printf '%s/nightly-product-eval.json' "$LOG_DIR"
+}
+
+write_json_report() {
+ local path="$1"
+ local mode="$2"
+ local scenarios_json="$3"
+ local artifacts_json="$4"
+ mkdir -p "$LOG_DIR"
+ node - "$path" "$mode" "$(git_revision)" "$ROOT_DIR" "$scenarios_json" "$artifacts_json" <<'NODE'
+const fs = require('fs');
+const path = require('path');
+
+const [,, filePath, mode, revision, rootDir, scenariosJson, artifactsJson] = process.argv;
+const payload = {
+ timestamp: new Date().toISOString(),
+ mode,
+ revision,
+ environment: path.basename(rootDir),
+ scenarios: JSON.parse(scenariosJson),
+ artifacts: JSON.parse(artifactsJson),
+};
+fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
+NODE
+}
+
+run_socketio_smoke() {
+ local port="$1"
+ local output_file="$2"
+ mkdir -p "$LOG_DIR"
+ node "$ROOT_DIR/scripts/nightly_product_eval_socketio_smoke.js" "$port" >"$output_file" 2>&1
+}
+
+probe_identity() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/api/app-identity"
+}
+
+probe_root() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/"
+}
+
+probe_static_asset() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/techmore.png" >/dev/null
+}
+
+probe_static_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/app_bootstrap.js" >/dev/null
+}
+
+probe_site_chrome_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/site_chrome.js" >/dev/null
+}
+
+probe_report_generation_ui_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/report_generation_ui.js" >/dev/null
+}
+
+probe_settings_tab_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/settings_tab.js" >/dev/null
+}
+
+probe_update_modal_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/update_modal.js" >/dev/null
+}
+
+probe_auto_scan_ui_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/auto_scan_ui.js" >/dev/null
+}
+
+probe_asset_details_modal_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/asset_details_modal.js" >/dev/null
+}
+
+probe_customer_ui_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/customer_ui.js" >/dev/null
+}
+
+probe_table_sorter_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/table_sorter.js" >/dev/null
+}
+
+probe_auto_update_banner_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/auto_update_banner.js" >/dev/null
+}
+
+probe_reports_tab_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/reports_tab.js" >/dev/null
+}
+
+probe_scan_runtime_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/scan_runtime.js" >/dev/null
+}
+
+probe_scan_banners_js() {
+ local port="$1"
+ curl -fsS "http://127.0.0.1:${port}/static/js/scan_banners.js" >/dev/null
+}
+
+start_server() {
+ (cd "$ROOT_DIR" && PORT="$DEFAULT_PORT" TRACEROUTE_PATH=/usr/bin/true npm start) >"$(server_log)" 2>&1 &
+ echo $!
+}
+
+stop_server() {
+ local pid="$1"
+ if [[ -z "$pid" ]]; then
+ return
+ fi
+ if kill -0 "$pid" >/dev/null 2>&1; then
+ kill "$pid" >/dev/null 2>&1 || true
+ wait "$pid" >/dev/null 2>&1 || true
+ fi
+}
+
+print_dry_run() {
+ cat </tmp/nightly-product-eval-port.$$ 2>/dev/null; then
+ active_port="$(cat /tmp/nightly-product-eval-port.$$)"
+ rm -f /tmp/nightly-product-eval-port.$$
+ break
+ fi
+ sleep 1
+ done
+
+ if [[ -z "$active_port" ]]; then
+ printf 'Server did not report a listening port.\n' >"$runtime_log"
+ write_json_report "$(json_log)" "run" '[
+ {"name": "app_start", "status": "blocked", "reason": "server did not start"},
+ {"name": "identity_probe", "status": "blocked", "reason": "server did not start"},
+ {"name": "root_probe", "status": "blocked", "reason": "server did not start"}
+ ]' "$(printf '%s' "[\"$runtime_log\", \"$(server_log)\"]")"
+ printf '%s nightly-product-eval blocked: server did not report a listening port\n' "$(timestamp)"
+ exit 1
+ fi
+
+ identity_json="$(probe_identity "$active_port")"
+ root_html="$(probe_root "$active_port")"
+ probe_static_asset "$active_port"
+ probe_static_js "$active_port"
+ probe_site_chrome_js "$active_port"
+ probe_report_generation_ui_js "$active_port"
+ probe_settings_tab_js "$active_port"
+ probe_update_modal_js "$active_port"
+ probe_auto_scan_ui_js "$active_port"
+ probe_asset_details_modal_js "$active_port"
+ probe_customer_ui_js "$active_port"
+ probe_table_sorter_js "$active_port"
+ probe_auto_update_banner_js "$active_port"
+ probe_reports_tab_js "$active_port"
+ probe_scan_runtime_js "$active_port"
+ probe_scan_banners_js "$active_port"
+ printf '%s\n' "$identity_json"
+ printf '%s\n' "$root_html" | sed -n '1,5p'
+ {
+ printf '%s\n' "$(timestamp)"
+ printf 'identity=%s\n' "$identity_json"
+ printf 'root=%s\n' "$(printf '%s' "$root_html" | tr '\n' ' ' | cut -c1-200)"
+ printf 'static_asset=ok\n'
+ printf 'static_js=ok\n'
+ printf 'site_chrome_js=ok\n'
+ printf 'report_generation_ui_js=ok\n'
+ printf 'settings_tab_js=ok\n'
+ printf 'update_modal_js=ok\n'
+ printf 'auto_scan_ui_js=ok\n'
+ printf 'asset_details_modal_js=ok\n'
+ printf 'customer_ui_js=ok\n'
+ printf 'table_sorter_js=ok\n'
+ printf 'auto_update_banner_js=ok\n'
+ printf 'reports_tab_js=ok\n'
+ printf 'scan_runtime_js=ok\n'
+ printf 'scan_banners_js=ok\n'
+ } >"$runtime_log"
+ run_socketio_smoke "$active_port" "${LOG_DIR}/nightly-product-eval-socketio-smoke.log"
+ write_json_report "$(json_log)" "run" '[
+ {"name": "app_start", "status": "pass"},
+ {"name": "identity_probe", "status": "pass"},
+ {"name": "root_probe", "status": "pass"},
+ {"name": "static_asset_probe", "status": "pass"},
+ {"name": "static_js_probe", "status": "pass"},
+ {"name": "site_chrome_js_probe", "status": "pass"},
+ {"name": "report_generation_ui_js_probe", "status": "pass"},
+ {"name": "settings_tab_js_probe", "status": "pass"},
+ {"name": "update_modal_js_probe", "status": "pass"},
+ {"name": "auto_scan_ui_js_probe", "status": "pass"},
+ {"name": "asset_details_modal_js_probe", "status": "pass"},
+ {"name": "customer_ui_js_probe", "status": "pass"},
+ {"name": "table_sorter_js_probe", "status": "pass"},
+ {"name": "auto_update_banner_js_probe", "status": "pass"},
+ {"name": "reports_tab_js_probe", "status": "pass"},
+ {"name": "scan_runtime_js_probe", "status": "pass"},
+ {"name": "scan_banners_js_probe", "status": "pass"},
+ {"name": "socketio_runtime_smoke", "status": "pass"},
+ {"name": "socketio_integration_smoke", "status": "pass"}
+ ]' "$(printf '%s' "[\"$runtime_log\", \"$(server_log)\", \"${LOG_DIR}/nightly-product-eval-socketio-smoke.log\"]")"
+ stop_server "${server_pid:-}"
+ printf '%s nightly-product-eval completed successfully\n' "$(timestamp)"
+ ;;
+ --record)
+ mkdir -p "$LOG_DIR"
+ local record_log
+ record_log="${LOG_DIR}/nightly-product-eval.record.log"
+ {
+ printf '%s nightly-product-eval record mode\n' "$(timestamp)"
+ printf 'root=%s\n' "$ROOT_DIR"
+ printf 'target=%s\n' "$SAFE_TARGET"
+ } >"$record_log"
+ printf 'Wrote %s\n' "$record_log"
+ ;;
+ *)
+ echo "Usage: $0 [--dry-run|--record|--run]" >&2
+ exit 1
+ ;;
+ esac
+}
+
+main "$@"
diff --git a/scripts/nightly_product_eval_socketio_smoke.js b/scripts/nightly_product_eval_socketio_smoke.js
new file mode 100644
index 00000000..4f4ac413
--- /dev/null
+++ b/scripts/nightly_product_eval_socketio_smoke.js
@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+const { io } = require('socket.io-client');
+
+const port = Number(process.argv[2] || 9000);
+const target = `http://127.0.0.1:${port}`;
+
+function connectClient() {
+ return new Promise((resolve, reject) => {
+ const client = io(target, {
+ transports: ['polling', 'websocket'],
+ timeout: 10000,
+ });
+
+ const fail = (error) => {
+ try { client.close(); } catch {}
+ reject(error instanceof Error ? error : new Error(String(error)));
+ };
+
+ client.on('connect_error', fail);
+ client.on('sync_state', (payload) => {
+ if (!payload || !payload.version) {
+ fail(new Error('sync_state did not include a version'));
+ return;
+ }
+ client.close();
+ resolve(payload);
+ });
+ client.on('disconnect', (reason) => {
+ if (reason !== 'io client disconnect') {
+ fail(new Error(`unexpected disconnect: ${reason}`));
+ }
+ });
+ });
+}
+
+connectClient()
+ .then((payload) => {
+ process.stdout.write(JSON.stringify({ success: true, payload }) + '\n');
+ })
+ .catch((error) => {
+ process.stderr.write(JSON.stringify({ success: false, error: error.message }) + '\n');
+ process.exit(1);
+ });
diff --git a/server.js b/server.js
index d91b95f6..1cdcd69a 100644
--- a/server.js
+++ b/server.js
@@ -10,17 +10,29 @@ const cron = require('node-cron');
const axios = require('axios');
const xml2js = require('xml2js');
const crypto = require('crypto');
+const {
+ runGoogleDriveHelper,
+ getGoogleDriveRedirectUri,
+} = require('./google_drive_bridge');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
const APP_IDENTITY = 'tm-network-scanner';
-const PORT = Number(process.env.PORT || 9000);
+const PORT = Number(process.env.PORT || process.env.NMAPUI_PORT || 9000);
+const HOST = process.env.HOST || process.env.NMAPUI_HOST || '127.0.0.1';
+const DATA_DIR = process.env.NMAPUI_DATA_DIR
+ ? path.resolve(process.env.NMAPUI_DATA_DIR)
+ : __dirname;
+const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
+const HISTORY_PATH = path.join(DATA_DIR, 'history.json');
+const REPORTS_DIR = path.join(DATA_DIR, 'reports_archive');
+const WORK_DIR = path.join(DATA_DIR, 'work');
app.use(express.static(path.join(__dirname)));
app.use('/static', express.static(path.join(__dirname, 'static')));
-app.use('/reports', express.static(path.join(__dirname, 'reports_archive')));
+app.use('/reports', express.static(REPORTS_DIR));
app.get('/api/app-identity', (req, res) => {
res.json({ app: APP_IDENTITY, name: 'TM-NMapUI', version: VERSION });
@@ -31,7 +43,7 @@ app.get('/google-drive/oauth2callback', async (req, res) => {
'exchange-code',
'--code', req.query.code || '',
'--state', req.query.state || ''
- ]);
+ ], DATA_DIR);
if (result.success) {
const googleDrive = saveGoogleDriveConfig({ enabled: true });
logEvent(null, 'settings', 'Google Drive connected and sync enabled.');
@@ -57,9 +69,6 @@ let isTracerouteRunning = false;
let cachedNetworkInfo = null;
let cachedPublicIP = null;
-const CONFIG_PATH = path.join(__dirname, 'config.json');
-const HISTORY_PATH = path.join(__dirname, 'history.json');
-const REPORTS_DIR = path.join(__dirname, 'reports_archive');
const NMAP_PATH = resolveExecutable(process.env.NMAP_PATH, [
'/opt/homebrew/bin/nmap',
'/usr/local/bin/nmap',
@@ -88,7 +97,9 @@ const GOWITNESS_PATH = resolveExecutable(process.env.GOWITNESS_PATH, [
let customerProfileConfig = loadJSON(CONFIG_PATH, {}).customerProfile || {};
let appConfig = loadJSON(CONFIG_PATH, {});
-if (!fs.existsSync(REPORTS_DIR)) fs.mkdirSync(REPORTS_DIR);
+if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
+if (!fs.existsSync(REPORTS_DIR)) fs.mkdirSync(REPORTS_DIR, { recursive: true });
+if (!fs.existsSync(WORK_DIR)) fs.mkdirSync(WORK_DIR, { recursive: true });
function loadJSON(filePath, defaultVal = {}) {
if (fs.existsSync(filePath)) {
@@ -540,32 +551,6 @@ function getCustomerFingerprintProfile() {
};
}
-function runGoogleDriveHelper(args, input = null) {
- return new Promise((resolve) => {
- const child = spawn('python3', [path.join(__dirname, 'google_drive.py'), ...args, '--root', __dirname]);
- 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() {
- return `http://localhost:${PORT}/google-drive/oauth2callback`;
-}
-
async function uploadReportFilesToDrive(socket, filePaths, profile, context = {}) {
const googleDrive = appConfig.googleDrive || {};
const label = context.label || 'report';
@@ -589,7 +574,7 @@ async function uploadReportFilesToDrive(socket, filePaths, profile, context = {}
const destination = googleDrive.folderId ? `folder ID ${googleDrive.folderId}` : folderName;
logEvent(socket, 'report', `Google Drive upload starting for ${label}: ${fileNames} -> ${destination}`);
- const result = await runGoogleDriveHelper(args);
+ const result = await runGoogleDriveHelper(args, DATA_DIR);
if (result.success) {
if (result.folder_id && !googleDrive.folderId) saveGoogleDriveConfig({ folderId: result.folder_id });
const uploadedNames = (result.uploaded || []).map(file => file.name).filter(Boolean).join(', ') || `${existingFiles.length} file(s)`;
@@ -641,8 +626,8 @@ function buildPhase2Args(usePn = false, fullPortScan = false, options = {}) {
...(scripts.length ? ['--script', scripts.join(',')] : []),
...scriptArgs,
'--stylesheet', 'nmap-modern.xsl',
- '-oX', 'phase2_results.xml',
- '-iL', 'targets.tmp'
+ '-oX', path.join(WORK_DIR, 'phase2_results.xml'),
+ '-iL', path.join(WORK_DIR, 'targets.tmp')
];
return args;
}
@@ -1243,11 +1228,10 @@ function runNmapFallbackPass(socket, args, phase, xmlFile, label, options = {})
}
async function runPhase2Fallback(socket, originalDuration, reportScanKind) {
- const noScriptXml = 'phase2_no_script.xml';
- const vulnersXml = 'phase2_vulners.xml';
+ const noScriptXml = path.join(WORK_DIR, 'phase2_no_script.xml');
+ const vulnersXml = path.join(WORK_DIR, 'phase2_vulners.xml');
[noScriptXml, vulnersXml].forEach(file => {
- const filePath = path.join(__dirname, file);
- if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+ if (fs.existsSync(file)) fs.unlinkSync(file);
});
logEvent(socket, 'job', 'Starting Phase 2 fallback recovery as two passes: 2.1 service/OS detection, then 2.2 vulners only.');
@@ -1256,7 +1240,7 @@ async function runPhase2Fallback(socket, originalDuration, reportScanKind) {
'-T3',
'--open',
'-oX', noScriptXml,
- '-iL', 'targets.tmp'
+ '-iL', path.join(WORK_DIR, 'targets.tmp')
], 2.1, noScriptXml, 'service + OS detection without scripts', { scanKind: reportScanKind });
if (!pass21.success) {
logEvent(socket, 'error', `Phase 2.1 fallback failed: ${pass21.error}`);
@@ -1273,7 +1257,7 @@ async function runPhase2Fallback(socket, originalDuration, reportScanKind) {
'-sV',
'--script', 'vulners',
'--script-args', 'threads=8',
- '-iL', 'targets.tmp',
+ '-iL', path.join(WORK_DIR, 'targets.tmp'),
'-oX', vulnersXml
], 2.2, vulnersXml, 'vulners only', { scanKind: reportScanKind });
if (!pass22.success) {
@@ -1306,7 +1290,7 @@ function runNmap(socket, args, phase = 1, onComplete = null, options = {}) {
if (phase === 1) {
discoveredHosts = [];
// Ensure XML is gone so we don't parse stale data
- const oldXml = path.join(__dirname, 'phase2_results.xml');
+ const oldXml = path.join(WORK_DIR, 'phase2_results.xml');
if (fs.existsSync(oldXml)) fs.unlinkSync(oldXml);
}
@@ -1387,7 +1371,7 @@ function runNmap(socket, args, phase = 1, onComplete = null, options = {}) {
io.emit('phase_complete', { phase, duration, ...phaseStats });
if (phase >= 2) {
- const xmlPath = path.join(__dirname, 'phase2_results.xml');
+ const xmlPath = path.join(WORK_DIR, 'phase2_results.xml');
const reportScanKind = options.scanKind || currentScanKind;
setTimeout(() => {
if (!isCompleteNmapXML(xmlPath)) {
@@ -1519,7 +1503,7 @@ function startChainedScan(socket, target, usePn = false, options = {}) {
return;
}
const discoveredTargets = discoveredHosts.map(h => h.ip).join('\n');
- fs.writeFileSync('targets.tmp', discoveredTargets);
+ fs.writeFileSync(path.join(WORK_DIR, 'targets.tmp'), discoveredTargets);
if (usePn) {
const vpnHelper = !!options.vpnHelper;
@@ -1565,7 +1549,7 @@ io.on('connection', (socket) => {
socket.on('start_dragnet_scan', (data) => {
if (discoveredHosts.length === 0) { logEvent(socket, 'error', 'No hosts discovered in Phase 1.'); return; }
const targets = discoveredHosts.map(h => h.ip).join('\n');
- fs.writeFileSync('targets.tmp', targets);
+ fs.writeFileSync(path.join(WORK_DIR, 'targets.tmp'), targets);
runNmap(socket, buildPhase2Args(true, true), 3, null, { scanKind: 'dragnet' });
});
socket.on('stop_scan', () => { if (currentScan) currentScan.kill(); });
@@ -1587,7 +1571,7 @@ io.on('connection', (socket) => {
socket.on('get_history', () => socket.emit('history_data', loadJSON(HISTORY_PATH, [])));
socket.on('get_customer_profile', () => socket.emit('customer_profile', getCustomerFingerprintProfile()));
socket.on('get_google_drive_status', async () => {
- const status = await runGoogleDriveHelper(['status']);
+ const status = await runGoogleDriveHelper(['status'], DATA_DIR);
if (status.connected && !getGoogleDriveConfig().enabled) {
saveGoogleDriveConfig({ enabled: true });
logEvent(socket, 'settings', 'Google Drive connection found; sync enabled on server.');
@@ -1595,17 +1579,17 @@ io.on('connection', (socket) => {
socket.emit('google_drive_status', { ...status, config: getGoogleDriveConfig() });
});
socket.on('save_google_drive_credentials', async (data = {}) => {
- const result = await runGoogleDriveHelper(['save-credentials'], data.credentialsJson || '');
+ const result = await runGoogleDriveHelper(['save-credentials'], DATA_DIR, data.credentialsJson || '');
const googleDrive = result.success ? saveGoogleDriveConfig({ enabled: true }) : getGoogleDriveConfig();
if (result.success) logEvent(socket, 'settings', 'Google Drive credentials imported and sync enabled.');
socket.emit('google_drive_status', { ...result, config: googleDrive });
});
socket.on('connect_google_drive', async () => {
- const result = await runGoogleDriveHelper(['auth-url', '--redirect-uri', getGoogleDriveRedirectUri()]);
+ const result = await runGoogleDriveHelper(['auth-url', '--redirect-uri', getGoogleDriveRedirectUri(PORT)], DATA_DIR);
socket.emit('google_drive_auth_url', result);
});
socket.on('disconnect_google_drive', async () => {
- const result = await runGoogleDriveHelper(['disconnect']);
+ const result = await runGoogleDriveHelper(['disconnect'], DATA_DIR);
const googleDrive = saveGoogleDriveConfig({ enabled: false });
if (result.success) logEvent(socket, 'settings', 'Google Drive disconnected and sync disabled.');
socket.emit('google_drive_status', { ...result, config: googleDrive });
@@ -1710,8 +1694,12 @@ function listenWithPortGuard(retried = false) {
}
listenWithPortGuard(true);
});
- server.listen(PORT);
+ server.listen(PORT, HOST);
}
-server.on('listening', () => console.log(`${VERSION} Server running on http://localhost:${PORT}`));
+server.on('listening', () => {
+ const address = server.address();
+ const activePort = address && typeof address === 'object' ? address.port : PORT;
+ console.log(`${VERSION} Server running on http://localhost:${activePort}`);
+});
listenWithPortGuard();