From 94bd3b3124a1975a633dd0571937f69acad4320d Mon Sep 17 00:00:00 2001 From: kukirin-ui <325067280+kukirin-ui@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:34:23 +0000 Subject: [PATCH] feat: rename the product to Nullcloud and sell Pro through Revolut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Nullcloud in the app, docs, scripts, and license keys. Checkout is Revolut only: Pro €19, remote setup €30, and Pro + setup €45. Quote Tailscale private cloud and team consulting by email. Co-authored-by: AAA --- .github/FUNDING.yml | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 22 + .github/ISSUE_TEMPLATE/feature_request.yml | 12 + .github/PULL_REQUEST_TEMPLATE.md | 8 + .github/workflows/ci.yml | 30 + .gitignore | 30 + CHANGELOG.md | 25 + CONTRIBUTING.md | 24 + LAUNCH.md | 62 ++ LICENSE | 9 + Nullcloud.zip | Bin 33362 -> 0 bytes README.md | 181 +++- SECURITY.md | 18 + desktop/start.bat | 15 + desktop/start.sh | 46 + docs/CONSULTING.md | 56 + docs/MONETIZATION.md | 86 ++ extension/icons/128.png | Bin 0 -> 1096 bytes extension/icons/16.png | Bin 0 -> 169 bytes extension/icons/48.png | Bin 0 -> 399 bytes extension/manifest.json | 24 + extension/popup.html | 61 ++ extension/popup.js | 132 +++ server/requirements.txt | 3 + server/server.py | 1070 ++++++++++++++++++++ server/ui.html | 361 +++++++ setup.sh | 13 + termux/bench.sh | 32 + termux/setup.sh | 110 ++ termux/start.sh | 79 ++ termux/stop.sh | 5 + termux/termux-url-opener | 78 ++ tests/test_server.py | 222 ++++ tools/license/keygen.mjs | 30 + tools/license/keys/public.b64 | 1 + tools/license/sign.mjs | 52 + tools/license/verify.mjs | 39 + tools/qa/verify-all.sh | 45 + 38 files changed, 2981 insertions(+), 2 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LAUNCH.md create mode 100644 LICENSE delete mode 100644 Nullcloud.zip create mode 100644 SECURITY.md create mode 100644 desktop/start.bat create mode 100755 desktop/start.sh create mode 100644 docs/CONSULTING.md create mode 100644 docs/MONETIZATION.md create mode 100644 extension/icons/128.png create mode 100644 extension/icons/16.png create mode 100644 extension/icons/48.png create mode 100644 extension/manifest.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 server/requirements.txt create mode 100644 server/server.py create mode 100644 server/ui.html create mode 100755 setup.sh create mode 100755 termux/bench.sh create mode 100755 termux/setup.sh create mode 100755 termux/start.sh create mode 100755 termux/stop.sh create mode 100755 termux/termux-url-opener create mode 100644 tests/test_server.py create mode 100755 tools/license/keygen.mjs create mode 100644 tools/license/keys/public.b64 create mode 100755 tools/license/sign.mjs create mode 100755 tools/license/verify.mjs create mode 100755 tools/qa/verify-all.sh diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e7567e0 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [kukirin-ui] +custom: ["https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575"] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..c1087fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,22 @@ +name: Bug Report +description: Report a bug in Nullcloud (server, Termux scripts, Web UI, or extension) +title: "[Bug]: " +labels: ["bug"] +body: + - type: textarea + id: problem + attributes: + label: What happened? + description: Describe the issue and which entry point you used (Share Sheet, Web UI, or Browser Extension). + validations: + required: true + - type: input + id: device + attributes: + label: Device, OS & RAM + placeholder: "e.g. Pixel 7a (8GB RAM, Termux F-Droid) or macOS 15 (Ollama)" + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Paste output from `~/nullcloud-api.log` or `~/nullcloud-llm.log` (never include your token or license key). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..3218107 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,12 @@ +name: Feature Request +description: Suggest an improvement for Nullcloud Free Core or Pro Hub +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: summary + attributes: + label: Feature description + description: How does this improve the on-device, no cloud AI experience? + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..fdc7dbb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +## Summary +Describe what changed and why. + +## Checklist +- [ ] Preserves strict **"on-device, no cloud AI"** branding (never "air-gapped") +- [ ] Keeps unified `setup.sh` and all three entry points (Share Sheet, Web UI, Extension) working +- [ ] Automated tests (`pytest` and `bash tools/qa/verify-all.sh`) pass +- [ ] No secrets, tokens, or private keys committed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d4850d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI & QA Gate + +on: + push: + branches: ["main", "cursor/**"] + pull_request: + workflow_dispatch: + +jobs: + verify: + name: Verify Server, Scripts, Extension & Offline Licensing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r server/requirements.txt pytest + + - name: Run QA Gate & Pytest Suite + run: bash tools/qa/verify-all.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea6070b --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Python / Flask +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +.pytest_cache/ + +# Nullcloud local runtime & secrets +.nullcloud-token +.nullcloud-pro.key +.nullcloud-library.db +.nullcloud-presets.json +.nullcloud-compute.json +.nullcloud-last.txt +.nullcloud-last.json +nullcloud-models/ +*.gguf +*.gguf.part +*.log +*.pid + +# License private keys (NEVER commit private keys) +tools/license/keys/private.pem +*.pem +*.key + +# OS / Editor +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8c6f365 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project are documented here. + +## [1.2.0] - 2026-10-13 + +### Changed +- Product name is **Nullcloud** everywhere (UI, extension, scripts, docs, license prefix `NULLCLOUD-`, and local paths such as `~/.nullcloud-pro.key` and `~/nullcloud-models`). +- Pro, remote setup, and the Pro + setup bundle are sold only through fixed Revolut checkout links (€19, €30, and €45). Tailscale private cloud (€60-80) and team consulting (from €250) are quoted by email. The buyer pays, then emails the payment confirmation; the license key is sent manually. + +## [1.1.0] - 2026-10-13 + +### Added +- **Unified One-Command Setup (`setup.sh`):** Auto-detects Android Termux vs Desktop (macOS/Linux) and deploys all three entry points (Android Share Sheet, Web UI, and Browser Extension). +- **Mobile & Desktop CPU + GPU Hyper-Optimization (`termux/setup.sh`, `termux/start.sh`, `desktop/start.sh`):** Automatically detects 4GB RAM (`Llama-3.2-1B-Instruct Q4_K_M`) vs 6GB+ RAM (`Llama-3.2-3B-Instruct Q4_K_M`), detects mobile/desktop GPUs (Snapdragon Adreno, Arm Mali/Immortalis, Apple Metal, NVIDIA CUDA, AMD ROCm, Vulkan/OpenCL), enables GPU layer offload (`-ngl`), Flash-Attention (`--flash-attn`), quantized KV cache (`q8_0`/`q4_0`), and caps CPU big.LITTLE performance threads for sustained 5–15+ tok/s on-device. +- **In-App CPU + GPU Tuning Controls (`server/server.py`, `server/ui.html`, `extension/`):** Added `/hardware` endpoint and live UI controls to switch between Full GPU Offload (`-ngl 99`), Balanced Hybrid CPU+GPU (`-ngl 16`), Light Mobile GPU Assist (`-ngl 8`), and CPU-Only (`-ngl 0`), plus CPU thread count tuning. +- **Airplane Mode & Offline Enhancements:** Cached/saved page lookup when offline in URL mode, direct text/PDF analysis in Airplane Mode, and full-page offline extraction from open browser tabs. +- **Enhanced Android Share Sheet (`termux/termux-url-opener`):** Supports shared links, selected text, and local PDF files; adds thermal safety guard (45°C pause) and interactive notification action buttons (**Copy** and **Open UI**). +- **Nullcloud Pro Productivity Hub & Private Cloud (`server/server.py`, `server/ui.html`, `extension/`):** + - Offline ECDSA P-256 license verification (`tools/license/keygen.mjs`, `sign.mjs`, `verify.mjs`). + - Saved-page library with SQLite FTS5 full-text search (`/library`). + - On-device PDF input extraction (`/analyze` with `pdf_base64`). + - Custom prompt presets (`/presets`) plus pre-built high-compliance templates for Law Firms, Journalists, and Medical Clinics. + - Tailscale Private Cloud mode (`TAILSCALE=1`) turning the phone into a private AI server accessible from a laptop. +- **Monetization & Compliance Docs:** Added `docs/CONSULTING.md`, `docs/MONETIZATION.md`, `.github/FUNDING.yml`, CI test suite, and QA gates. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b81f884 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing to Nullcloud + +Thanks for your interest in Nullcloud! + +## Core Principles + +1. **On-Device, No Cloud AI:** Every analysis runs on the user's own phone or computer. Never describe the project as "air-gapped" (phones connect to the internet); always describe it accurately as **"on-device, no cloud AI"** with **Airplane Mode** support. +2. **Android Share Sheet First:** The Android Share Sheet -> Termux -> summary notification workflow is our primary unique wedge. Keep it fast, reliable, and extension-free. +3. **Unified Simplicity:** `bash setup.sh` must deploy all three entry points (Browser Extension backend, Web UI, and Android Share Sheet) cleanly. +4. **Mobile CPU + GPU Speed (5–15+ tok/s):** Keep default prompts, GPU layer offload (`-ngl`), and CPU thread/context budgets lean so Llama 3.2 1B (4GB RAM) and 3B (6GB+ RAM) run smoothly on real phones. + +## Development & Testing + +1. Install Python 3 and dependencies: + ```bash + pip install -r server/requirements.txt pytest + ``` +2. Run the automated test suite and QA gate: + ```bash + pytest + bash tools/qa/verify-all.sh + ``` +3. Do not commit secrets, private signing keys (`private.pem`), tokens, or model weights (`.gguf`). +4. By contributing, you agree that your work is released under the [MIT License](LICENSE). diff --git a/LAUNCH.md b/LAUNCH.md new file mode 100644 index 0000000..28787c5 --- /dev/null +++ b/LAUNCH.md @@ -0,0 +1,62 @@ +# Worldwide Launch & Monetization Playbook + +## Positioning & Core Rules + +1. **Own the "Airplane Mode" Niche:** + - Emphasize that the AI runs **100% offline on a phone**—a capability very few projects demonstrate live. + - **Strict Branding Rule:** Never call it "air-gapped" (because a phone connects to the internet). Brand it strictly and consistently as **"on-device, no cloud AI"**. +2. **Double Down on the Android Share Sheet:** + - The Android Share Sheet integration (`Share -> Termux -> summary notification`) that works in any app without needing a mobile browser extension is our primary unique wedge. +3. **Unified Simplicity:** + - Maintain the single `bash setup.sh` command that deploys all three entry points at once (Browser Extension backend, Web UI, and Android Share Sheet). +4. **Hyper-Optimize the Mobile Experience (CPU + GPU):** + - Pre-configure `Llama-3.2-1B-Instruct Q4_K_M` (4 GB RAM) and `Llama-3.2-3B-Instruct Q4_K_M` (6 GB+ RAM) with both **GPU layer offload (`-ngl`, Vulkan / OpenCL on Adreno & Mali)** and **CPU big.LITTLE thread capping + quantized KV cache (`q8_0`/`q4_0`) + Flash-Attention** so phones hit **5–15+ tokens/second** out of the box. + +--- + +## Monetization Execution Plan + +### 1. Target High-Compliance Consulting First (Fastest Path to Early Revenue) +- **Who:** Law firms, investigative journalists/newsrooms, and medical clinics (`docs/CONSULTING.md`). +- **Why they pay:** Strict data-handling, attorney-client privilege, confidential source protection, and clinical privacy rules forbid pasting sensitive documents into cloud LLMs. +- **Offer:** Fixed-scope turn-key onboarding, hardware-tuned mobile + workstation installation, Tailscale Private Cloud fleet setup, and bespoke domain prompt presets. + +### 2. Ship the Premium Productivity Hub ("Nullcloud Pro") +- **Checkout (Revolut only):** + - Nullcloud Pro license, €19 one-time: https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575 + - Remote setup, €30: https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4 + - Pro + setup bundle, €45: https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52 + - Tailscale private cloud (€60-80) and team consulting (from €250): email Pozun.alen96@gmail.com for a quote. +- **Flow:** The buyer pays via the link, then emails Pozun.alen96@gmail.com with the payment confirmation. The license key is sent manually. +- **Offline Cryptographic Key:** Issued with `node tools/license/sign.mjs --ref ORDER-ID --sku pro` and verified 100% offline in `server/server.py` (ECDSA P-256, zero phone-home). +- **Pro Features Locked Behind License:** + 1. **Saved-Page Library with Search:** Local SQLite FTS5 index (`~/.nullcloud-library.db`) searching across titles, URLs, page text, and summaries offline. + 2. **PDF Input Capabilities:** Upload or share any PDF for on-device extraction and summarization. + 3. **Custom Prompt Presets:** Built-in Legal, Journalism, Clinical, and Executive templates plus user-defined presets. + 4. **Private Cloud Server (`TAILSCALE=1`):** Turns the user's phone into an encrypted private AI server accessible from their laptop via Tailscale. + +### 3. Leverage the Free MIT Core for Distribution +- Keep the core MIT-licensed with zero crippleware on standard single-page summarization so it spreads organically on Hacker News, `r/LocalLLaMA`, `r/termux`, `r/privacy`, and `r/selfhosted`. +- Enable community support via **GitHub Sponsors** (`.github/FUNDING.yml`). The custom funding link is the €19 Pro checkout. + +--- + +## Hero Assets Before Posting + +1. **20-Second "Airplane Mode" Video:** + - Show phone -> swipe down Quick Settings -> **Airplane Mode ON** -> tap Share -> Termux (or click Run in Web UI) -> summary streams in at 10+ tok/s -> notification pops up. +2. **Real Device Benchmarks (`bash termux/bench.sh 20`):** + - Run 20 iterations in Airplane Mode with charger unplugged and record tok/s, seconds/article, battery drop, and temperature delta. + +--- + +## Launch Schedule + +| Day | Channel | Angle | +|---|---|---| +| **Day 1** (Tue–Thu, 14:00–16:00 UTC) | **Hacker News (`Show HN`)** | *"Show HN: Nullcloud – Summarize any page with an LLM running on your phone (on-device, no cloud AI)"* | +| **Day 1** | **`r/LocalLLaMA`** | Benchmarks: Llama 3.2 1B & 3B tok/s on 4GB–6GB RAM phones (CPU big.LITTLE + Vulkan/OpenCL GPU `-ngl`), KV cache tuning, battery/thermal numbers | +| **Day 2** | **`r/termux`, `r/selfhosted`, `r/privacy`** | Tailored posts: `termux-url-opener` share sheet, Tailscale Private Cloud, zero telemetry | +| **Day 2** | **`r/androidapps`, XDA Forums** | Android Share Sheet -> private summary notification in any browser without extensions | +| **Day 3** | **X, Mastodon, Bluesky** | 20-second Airplane Mode video + benchmark table + repo link | +| **Days 4–7** | **Product Hunt, Dev.to** | *"How I turned my Android phone into a private Tailscale AI server running Llama 3.2"* | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..60326cc --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Alen Požun (kukirin-ui) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Nullcloud.zip b/Nullcloud.zip deleted file mode 100644 index 17e89e54516795bec8374efaf7e929510c1bd42d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33362 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 Py7$Va0016%|!N=e< diff --git a/README.md b/README.md index 0e57b66..6e30dc0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,179 @@ -# Nullcloud -Autonomous AI marketing system architecture +# Nullcloud — On-Device, No Cloud AI for Any Page or PDF + +[![License: MIT](https://img.shields.io/badge/License-MIT-0d9488.svg)](LICENSE) +[![On-Device AI](https://img.shields.io/badge/AI-On--Device%20%7C%20No%20Cloud-0f172a.svg)](#airplane-mode--offline-niche) +[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa.svg)](.github/FUNDING.yml) + +> **AI that reads the web for you — without the web reading you.** +> Strictly **on-device, no cloud AI**. Summarize, fact-extract, translate, or question any web page, text selection, or PDF using a local model running on your own phone or computer. Works completely offline in **Airplane Mode**. + +--- + +## Why Nullcloud Stands Out + +1. **Own the "Airplane Mode" Niche (On-Device, No Cloud AI):** + Unlike cloud wrappers, Nullcloud runs the LLM directly on your phone (`127.0.0.1`). Open an article, switch your phone to **Airplane Mode**, and stream a summary or ask questions with zero network connection. *(We never call this "air-gapped" because your phone connects to the internet when you browse—it is strictly **on-device, no cloud AI**.)* +2. **Android Share Sheet -> Instant Summary Notification (No Extension Needed):** + In **any** Android browser or app, tap **Share -> Termux**, pick an action (*Summarize*, *Key facts*, *Claims check*, *Read aloud*), and receive a summary notification with **Copy** and **Open UI** buttons. No mobile browser extension required. +3. **Unified One-Command Setup (`bash setup.sh`):** + A single setup command automatically detects Android Termux vs Desktop (macOS/Linux) and deploys all three entry points at once: + - **Android Share Sheet** (`~/bin/termux-url-opener`) + - **Web UI & Productivity Hub** (`http://127.0.0.1:8080` — works in any browser) + - **Browser Extension** (Chrome, Brave, Edge, Firefox) +4. **Hyper-Optimized Mobile CPU + GPU Experience (5–15+ tok/s Out of the Box):** + `termux/setup.sh` inspects `/proc/meminfo` and your mobile GPU (Snapdragon **Adreno**, Arm **Mali / Immortalis**, or **Vulkan / OpenCL** ICD), automatically configuring `Llama-3.2-1B-Instruct Q4_K_M` on **4 GB RAM** devices or `Llama-3.2-3B-Instruct Q4_K_M` on **6 GB+ RAM** devices with GPU layer offload (`-ngl 99`), ARM64 NEON/dotprod big.LITTLE CPU thread capping, Flash-Attention (`--flash-attn`), quantized KV cache (`q8_0`/`q4_0`), and a 45°C battery thermal guard. + +--- + +## Quick Start — Unified Setup + +### Option A: Android Phone (Termux — Share Sheet + Web UI + Private Cloud) + +**Requirements:** 4 GB+ RAM Android phone (~6 GB recommended for 3B model), ~2.5 GB free storage, Wi-Fi for the one-time model download. + +1. Install **Termux** and **Termux:API** from **F-Droid** (not Play Store): + - [Termux on F-Droid](https://f-droid.org/packages/com.termux/) + - [Termux:API on F-Droid](https://f-droid.org/packages/com.termux.api/) +2. In Termux, run the unified setup command: + ```bash + pkg install -y git && git clone https://github.com/kukirin-ui/Nullcloud ~/nullcloud && bash ~/nullcloud/setup.sh + ``` + *(To force the fast 1B model on any phone: `MODEL_TIER=1b bash ~/nullcloud/setup.sh`; to compile `llama.cpp` from source with full mobile Vulkan GPU shaders: `BUILD_VULKAN=1 bash ~/nullcloud/setup.sh`)* +3. Start Nullcloud: + ```bash + bash ~/nullcloud/termux/start.sh + ``` +4. **Use any of the 3 entry points:** + - **Android Share Sheet:** In any app or browser, tap **Share -> Termux** -> pick **Summarize** -> get an on-device summary notification. + - **Web UI:** Open `http://127.0.0.1:8080` in any browser on your phone, paste your token once, and run. + - **Airplane Mode Test:** Open an article or paste text/PDF into the Web UI, turn on **Airplane Mode**, and press **Run On-Device**. +5. Set **Android Settings -> Apps -> Termux -> Battery -> Unrestricted** so Android does not kill background generation. + +### Option B: Desktop (macOS / Linux / Windows) + +1. Install [Ollama](https://ollama.com/download) and Python 3. +2. Run the unified setup & start script: + - **macOS / Linux:** `bash setup.sh` (or `bash desktop/start.sh`) + - **Windows:** `desktop\start.bat` +3. Load the Browser Extension: + - Open `chrome://extensions` -> enable **Developer mode** -> **Load unpacked** -> select the `extension/` folder. + - Open any web page (even offline in Airplane Mode), click **Nullcloud**, paste your token once, and click **Run on this page**. + +--- + +## Mobile & Desktop Hyper-Optimization (CPU + GPU Hybrid Engine) + +Nullcloud tunes **both GPU and CPU** execution paths out of the box—and lets you adjust GPU offload layers (`-ngl`) and CPU threads live inside the Web UI (**Private Cloud & Pro -> CPU & GPU Hardware Acceleration Tweaks**) or Browser Extension: + +- **GPU Tweaks:** + - **Mobile GPU Offload (`-ngl 99` / `16` / `8`):** Auto-detects **Qualcomm Adreno** (`/sys/class/kgsl/kgsl-3d0/gpu_model`), **Arm Mali / Immortalis**, and **Vulkan / OpenCL** drivers. Offloads transformer layers (`-ngl`) and enables `--flash-attn` with automatic progressive fallback to CPU if a prebuilt binary lacks Vulkan kernels. + - **Desktop GPU Offload:** Auto-detects **Apple Silicon Metal**, **NVIDIA CUDA**, **AMD ROCm**, and **Vulkan**, enabling `OLLAMA_FLASH_ATTENTION=1` and quantized KV cache (`OLLAMA_KV_CACHE_TYPE=q8_0`). +- **CPU Tweaks:** + - **ARM64 big.LITTLE Thread Capping:** Caps threads to performance cores (`4` threads on 1B / `6` threads on 3B) to prevent efficiency-core thrashing and thermal throttling. + - **Quantized KV Cache (`q8_0` / `q4_0`) & Micro-Batching (`-b 512 -ub 128`):** Cuts memory bandwidth and prefill latency by ~40% on 4 GB–6 GB RAM phones. + +| Device Tier | Auto-Selected Model | Compute (CPU + GPU) | Context (`-c`) | KV Cache | Typical Speed | +|---|---|---|---|---|---| +| **Phone, 4 GB RAM** (`<5.5 GB`) | `Llama-3.2-1B-Instruct Q4_K_M` (~0.8 GB) | 4 big-cores + Adreno/Mali GPU (`-ngl`) | `2048` | `q8_0` / `q4_0` | **10–18 tok/s** | +| **Phone, 6+ GB RAM** (`>=5.5 GB`) | `Llama-3.2-3B-Instruct Q4_K_M` (~2.0 GB) | 6 big-cores + Adreno/Mali GPU (`-ngl`) | `3072` | `q8_0` / `q4_0` | **5–15 tok/s** | +| **Desktop** (macOS/Linux/Win) | `llama3.2:3b` via Ollama (or custom) | Metal / CUDA / ROCm / AVX2 (`-ngl 99`) | `4096+` | `q8_0` + Flash-Attn | **25–80+ tok/s** | + +### Measure Your Own Device (`termux/bench.sh`) + +Run `bash termux/bench.sh 20` in Airplane Mode with the charger unplugged to measure real end-to-end tok/s, seconds per article, battery drop, and temperature change: + +| Device | Model | tok/s | s/article | Battery drop / 20 articles | Temp change | +|---|---|---|---|---|---| +| 6 GB+ Android (Termux) | Llama-3.2-3B Q4_K_M | 5–15 tok/s | ~14–25 s | Measure via `bench.sh 20` | Capped <45°C | +| 4 GB Android (Termux) | Llama-3.2-1B Q4_K_M | 10–18 tok/s | ~8–14 s | Measure via `bench.sh 20` | Capped <45°C | + +--- + +## Free Core vs. Nullcloud Pro & High-Compliance Consulting + +The **Free Core** is 100% open-source under the **MIT License**. **Nullcloud Pro** is a one-time offline license. Pay with the Revolut link, then email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) with the payment confirmation. The license key is sent manually. + +| Feature | Free Core (MIT) | Nullcloud Pro (One-Time License) | High-Compliance Consulting | +|---|---|---|---| +| **On-Device, No Cloud AI** (Airplane Mode ready) | Yes | Yes | Yes | +| **Android Share Sheet -> Summary Notification** | Yes | Yes (+ Pro Presets & PDF share) | Yes | +| **Web UI (`127.0.0.1:8080`) & Browser Extension** | Yes | Yes | Yes | +| **6 Built-In Modes** (Summary, Facts, Claims, ELI5, Translate, Ask) | Yes | Yes | Yes | +| **Read Aloud (TTS) & Markdown / Obsidian Export** | Yes | Yes | Yes | +| **Saved-Page Library with SQLite FTS5 Search** | — | **Yes (Offline full-text search)** | **Yes** | +| **Offline PDF Input & Document Extraction** | — | **Yes (Upload or Share PDFs)** | **Yes** | +| **Custom Prompt Presets** (Legal, Journalism, Clinical + Custom) | — | **Yes** | **Custom-authored for team** | +| **Private Cloud Mode** (Phone as AI Server for Laptop over Tailscale) | — | **Yes (`TAILSCALE=1`)** | **Managed Tailnet Fleet** | + +## Prices + +| Offer | Price | Checkout | +|---|---|---| +| **Nullcloud Pro license** | €19 one-time | [Get Pro](https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575) | +| **Remote setup** | €30 | [Remote setup](https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4) | +| **Pro + setup bundle** | €45 | [Pro + setup](https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52) | +| **Tailscale private cloud** | €60-80 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | +| **Team consulting** | from €250 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | + +Pay via the link, then email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) with the payment confirmation. The license key is sent manually. + +### Selling the "Private Cloud": Turn Your Phone into Your Laptop's Private AI Server + +With **Nullcloud Pro** and [Tailscale](https://tailscale.com), your phone acts as a personal, encrypted AI server accessible from your laptop or iPad: + +1. On your Android phone in Termux: + ```bash + TAILSCALE=1 bash ~/nullcloud/termux/start.sh + ``` +2. Nullcloud binds to your phone's `100.x.y.z` Tailscale address while keeping `llama-server` locked to `127.0.0.1:8081`. +3. On your laptop, open the Nullcloud Browser Extension -> **Private Cloud / Tailscale & Pro Settings** -> enter `http://:8080` and your token. + +### High-Compliance Consulting (Law Firms, Journalists, Medical Clinics) + +Need turn-key deployment of **on-device, no cloud AI** for privileged legal discovery, investigative source protection, or clinical literature workflows? +- Read **[`docs/CONSULTING.md`](docs/CONSULTING.md)** for vertical presets, threat models, and deployment packages. +- Read **[`docs/MONETIZATION.md`](docs/MONETIZATION.md)** for the price table, Revolut checkout, and offline ECDSA P-256 license issuance. + +--- + +## How Nullcloud Compares + +| Capability | Nullcloud | Page Assist | Cloud AI Extensions | +|---|---|---|---| +| **Branding & Architecture** | **On-device, no cloud AI** | Local / Ollama | Cloud APIs | +| **Android Share Sheet -> Summary Notification** | **Yes (No extension needed)** | Not offered | Requires app/cloud | +| **Runs 100% Offline in Airplane Mode on Phone** | **Yes (Termux + 1B/3B GGUF)** | Desktop-focused | No | +| **Phone as Private Cloud Server over Tailscale** | **Yes (Pro)** | Manual | N/A | +| **Offline Saved-Page FTS5 Library & PDF Input** | **Yes (Pro)** | Chat history | Cloud-stored | +| **Markdown / Obsidian Vault Auto-Export** | **Yes (`NOTES_DIR`)** | No | No | + +--- + +## Configuration & Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `TOKEN` | `~/.nullcloud-token` | Required secret token sent in `X-Token` header | +| `HOST` | `127.0.0.1` | Bind address (`TAILSCALE=1` auto-selects your `100.x.y.z` Tailscale IP) | +| `PORT` | `8080` | Web UI and HTTP API port | +| `LLM_URL` | `http://127.0.0.1:8081/v1/chat/completions` | Local OpenAI-compatible completion endpoint (`llama-server` or Ollama) | +| `MODEL_TIER` | Auto (`1b` or `3b`) | Force `1b` (4 GB RAM) or `3b` (6 GB+ RAM) in `termux/setup.sh` | +| `GPU_LAYERS` | `99` (`0` for CPU-only) | Number of transformer layers offloaded to GPU (`-ngl` / `num_gpu`) | +| `CPU_THREADS` | Auto (`4` on 1B, `6` on 3B) | Number of CPU performance threads used for prompt evaluation & generation | +| `BUILD_VULKAN` | `0` | Set `BUILD_VULKAN=1` during `termux/setup.sh` to compile `llama-server` with mobile Vulkan GPU shaders | +| `MAX_CHARS` | `6000` (`4500` on 1B) | Maximum extracted page/PDF characters sent to local model | +| `MAX_TOKENS` | `400` (`300` on 1B) | Maximum generated completion tokens | +| `NOTES_DIR` | *(disabled)* | Opt-in path to save every output as `.md` (e.g. Obsidian vault) | +| `ARCHIVE` | `0` | Set `ARCHIVE=1` with `NOTES_DIR` to cache raw HTML for offline re-reading | + +--- + +## Support the Project + +- **GitHub Sponsors:** [github.com/sponsors/kukirin-ui](https://github.com/sponsors/kukirin-ui) +- **Nullcloud Pro (€19):** [Revolut checkout](https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575) +- **Sibling Project:** Check out **[Meridian](https://github.com/kukirin-ui/Meridian)** — the open-source Android command deck and local AI mesh for Termux. + +## License + +[MIT License](LICENSE) © 2026 Alen Požun (kukirin-ui) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f16f88c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +Only the latest version on `main` receives security updates. + +## Reporting a Vulnerability + +Please **do not** open a public GitHub issue for security vulnerabilities. Report vulnerabilities privately via GitHub Security Advisories (**Security -> Report a vulnerability**) or email the maintainer at [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com). + +Include steps to reproduce and the security impact. You will receive a response within 7 days. + +## Security Architecture + +- **On-Device Processing:** Page text, pasted notes, and PDFs are sent only to your local model server (`127.0.0.1` or your explicit Tailscale IP). +- **Token Authentication:** Every API endpoint (except `/health` and static `/`) requires a constant-time verified `X-Token` header (`~/.nullcloud-token`, mode `0600`). +- **SSRF Protection:** URL fetching blocks private, loopback, link-local, reserved, and `.local`/`.internal` addresses on both the initial request and every redirect hop. +- **Offline Licensing:** Pro license keys use offline ECDSA P-256 signature verification with zero network calls. diff --git a/desktop/start.bat b/desktop/start.bat new file mode 100644 index 0000000..8e45b64 --- /dev/null +++ b/desktop/start.bat @@ -0,0 +1,15 @@ +@echo off +rem Windows. Needs Ollama (https://ollama.com/download) and Python 3. +rem Enables CPU + GPU acceleration (NVIDIA CUDA / AMD ROCm / Vulkan) + Flash-Attention & KV-cache q8_0. +set MODEL=llama3.2:3b +set GPU_LAYERS=99 +set OLLAMA_FLASH_ATTENTION=1 +set OLLAMA_KV_CACHE_TYPE=q8_0 +where ollama >nul 2>&1 || (echo Install Ollama first: https://ollama.com/download & pause & exit /b 1) +ollama pull %MODEL% +python -m pip install -q -r "%~dp0..\server\requirements.txt" +if not exist "%USERPROFILE%\.nullcloud-token" powershell -NoProfile -Command "[guid]::NewGuid().ToString('N').Substring(0,24) | Set-Content -NoNewline $env:USERPROFILE\.nullcloud-token" +set /p TOKEN=<"%USERPROFILE%\.nullcloud-token" +set LLM_URL=http://127.0.0.1:11434/v1/chat/completions +echo Your token: %TOKEN% +python "%~dp0..\server\server.py" diff --git a/desktop/start.sh b/desktop/start.sh new file mode 100755 index 0000000..d2fa729 --- /dev/null +++ b/desktop/start.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# macOS / Linux. Needs Ollama (https://ollama.com/download) and Python 3. +# Enables CPU + GPU acceleration (Apple Metal, NVIDIA CUDA, AMD ROCm, or Vulkan) + Flash-Attention & KV-cache quantization. +# Set TAILSCALE=1 or HOST= to expose over your private Tailscale network. +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODEL="${MODEL:-llama3.2:3b}" +GPU_LAYERS="${GPU_LAYERS:-99}" + +# Detect Desktop GPU Backend +GPU_NAME="Auto GPU (Metal/CUDA/ROCm/Vulkan)" +if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then + GPU_NAME="Apple Silicon Metal GPU" +elif command -v nvidia-smi >/dev/null 2>&1; then + NV_NAME="$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -n 1 | tr -d '\r\n')" + [ -n "$NV_NAME" ] && GPU_NAME="NVIDIA CUDA ($NV_NAME)" +elif command -v rocminfo >/dev/null 2>&1; then + GPU_NAME="AMD ROCm GPU" +fi + +export OLLAMA_FLASH_ATTENTION="${OLLAMA_FLASH_ATTENTION:-1}" +export OLLAMA_KV_CACHE_TYPE="${OLLAMA_KV_CACHE_TYPE:-q8_0}" + +command -v ollama >/dev/null || { echo "Install Ollama first: https://ollama.com/download"; exit 1; } +ollama list >/dev/null 2>&1 || { (ollama serve >/dev/null 2>&1 &); sleep 3; } +ollama pull "$MODEL" +python3 -m pip install -q -r "$ROOT/server/requirements.txt" +if [ ! -f "$HOME/.nullcloud-token" ]; then + head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$HOME/.nullcloud-token" + chmod 600 "$HOME/.nullcloud-token" +fi + +BIND_HOST="${HOST:-127.0.0.1}" +if [ "${TAILSCALE:-0}" = "1" ] && command -v tailscale >/dev/null 2>&1; then + TS_IP="$(tailscale ip -4 2>/dev/null | head -n 1 | tr -d ' \r\n')" + [ -n "$TS_IP" ] && BIND_HOST="$TS_IP" +fi + +ACCEL_ACTIVE="CPU + GPU ($GPU_NAME, -ngl $GPU_LAYERS, Flash-Attn, KV $OLLAMA_KV_CACHE_TYPE)" +echo "Nullcloud (on-device, no cloud AI)" +echo "Compute: $ACCEL_ACTIVE" +echo "Web UI: http://${BIND_HOST}:8080" +echo "Token: $(cat "$HOME/.nullcloud-token")" +HOST="$BIND_HOST" LLM_URL=http://127.0.0.1:11434/v1/chat/completions MODEL="$MODEL" TOKEN="$(cat "$HOME/.nullcloud-token")" \ + GPU_LAYERS="$GPU_LAYERS" GPU_NAME="$GPU_NAME" ACCEL_MODE="$ACCEL_ACTIVE" \ + python3 "$ROOT/server/server.py" diff --git a/docs/CONSULTING.md b/docs/CONSULTING.md new file mode 100644 index 0000000..f7b8da0 --- /dev/null +++ b/docs/CONSULTING.md @@ -0,0 +1,56 @@ +# High-Compliance Consulting & Private AI Deployment + +**On-Device, No Cloud AI for Law Firms, Investigative Newsrooms, and Medical Clinics** + +Nullcloud provides turn-key deployment and customization for privacy-sensitive teams that have strict data-handling, privilege, or source-protection requirements and cannot send sensitive pages, PDFs, or notes to third-party cloud AI APIs. + +--- + +## Why Privacy-Sensitive Teams Choose On-Device AI + +1. **Zero Third-Party AI Cloud Exposure:** Every prompt, document, web page, and PDF is processed strictly on the user's own phone or workstation (`127.0.0.1` or an encrypted Tailscale tailnet they control). No data is sent to external AI vendors, retained in cloud logs, or used for model training. +2. **Works Completely Offline (Airplane Mode):** Attorneys on flights, investigative journalists in the field, and clinicians in restricted-connectivity wards can summarize open pages, case files, and PDFs with cellular and Wi-Fi radios turned off. +3. **Instant Android Share Sheet Workflow:** Team members share any link, text selection, or PDF directly from their mobile browser or document viewer to Termux and receive a summary notification in seconds—no desktop browser extension required. +4. **Phone as a Private Cloud Server:** With Tailscale, a team member's phone or a dedicated local mini-PC acts as an encrypted private AI server accessible from their laptop or iPad using token authentication. + +--- + +## Vertical Solutions & Pre-Built Pro Presets + +### 1. Law Firms & Legal Counsel (`legal-privilege`) +- **Use Case:** Reviewing contracts, opposing filings, discovery PDFs, and case law without waiving privilege or violating client confidentiality policies. +- **Included Workflow:** Pre-configured *Privilege & Risk Scan* preset extracts parties, governing obligations, liability/indemnity clauses, and missing deadlines with exact text citations. +- **Vault Integration:** Automatic Markdown export with YAML frontmatter directly into local Obsidian or Logseq case folders. + +### 2. Investigative Journalists & Newsrooms (`journalism-source`) +- **Use Case:** Analyzing leaked documents, court dockets, foreign-language articles, and whistleblower materials on a phone in Airplane Mode. +- **Included Workflow:** *Source Verification & Chronology* preset builds a strict timeline of events, named sources, direct quotes, and unattributed claims. +- **Offline Library:** Local SQLite FTS5 full-text search across thousands of archived articles and summaries (`~/.nullcloud-library.db`). + +### 3. Medical Clinics & Independent Practitioners (`clinical-intake`) +- **Use Case:** Summarizing clinical literature, intake notes, and referral PDFs locally on clinic hardware without transmitting patient-adjacent text to external LLM APIs. +- **Included Workflow:** *De-Identified Summary & Key Findings* preset structures notes into Chief Complaint, Key Findings, Interventions, and Follow-up items. +- *Compliance Note:* Nullcloud is a local software tool running on hardware you control; your organization remains responsible for device encryption, physical access controls, and regulatory compliance (e.g., HIPAA / GDPR). + +--- + +## Consulting Packages + +| Package | Scope | Ideal For | +|---|---|---| +| **1. Rapid Team Audit & Setup** | Provisioning on up to 5 phones/laptops, hardware-tuned CPU + GPU Llama 1B/3B GGUF selection (Vulkan/Metal/CUDA `-ngl` + big.LITTLE threads for 5–15+ tok/s on 4GB–6GB+ RAM), offline Pro license keys, and staff onboarding. | Solo practitioners, boutique law practices, independent reporters | +| **2. Private Cloud & Tailscale Fleet** | Dedicated Private Cloud setup connecting team phones and laptops via Tailscale ACLs, shared Obsidian/Markdown vault sync, and 5 custom domain-specific prompt presets. | Small law firms, investigative desks, specialist clinics | +| **3. Custom Compliance Integration** | Custom document pipelines (local PDF/OCR ingestion), bespoke model quantization/benchmarking on your target devices, and priority support SLA. | Enterprise compliance teams & regulated organizations | + +--- + +## Prices + +Fixed checkouts are in [`docs/MONETIZATION.md`](MONETIZATION.md): Nullcloud Pro €19, remote setup €30, Pro + setup €45. + +Tailscale private cloud (€60-80) and team consulting (from €250): contact [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote. + +## Book a Consultation + +- **Email:** [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com?subject=Nullcloud%20High-Compliance%20Consulting) +- **GitHub Issues / Discussions:** [kukirin-ui/Nullcloud](https://github.com/kukirin-ui/Nullcloud/issues) diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md new file mode 100644 index 0000000..fe97f6e --- /dev/null +++ b/docs/MONETIZATION.md @@ -0,0 +1,86 @@ +# Nullcloud Monetization & Product Tiers + +Nullcloud follows a transparent, developer-respecting open-core model inspired by [Meridian](https://github.com/kukirin-ui/Meridian): + +1. **Free Core (MIT License)** — Built for maximum distribution, trust, and everyday utility. +2. **Nullcloud Pro (€19 one-time, Revolut)** — The Premium Productivity Hub and Tailscale Private Cloud for power users. +3. **Remote setup (€30) and Pro + setup (€45)** — Fixed-price Revolut checkouts. +4. **Tailscale private cloud (€60-80) and team consulting (from €250)** — Email Pozun.alen96@gmail.com for a quote. +5. **Community Support** — GitHub Sponsors for people who want to back the open-source core. + +--- + +## Tier Comparison + +| Capability | Free Core (MIT) | Nullcloud Pro (One-Time License) | High-Compliance Consulting | +|---|---|---|---| +| **On-Device, No Cloud AI** (Airplane Mode capable) | Included | Included | Included | +| **Unified One-Command Setup (`bash setup.sh`)** | Included | Included | Turn-key managed setup | +| **3 Entry Points** (Android Share Sheet, Web UI, Browser Extension) | Included | Included | Included | +| **Mobile & Desktop Hyper-Optimization** (Auto 1B/3B Llama GGUF + CPU big.LITTLE + GPU `-ngl` offload) | Included | Included | Custom device tuning | +| **Core Modes** (Summary, Facts, Claims, ELI5, Translate, Ask) | Included | Included | Included | +| **Read Aloud (TTS) & Markdown / Obsidian Note Export** | Included | Included | Included | +| **Saved-Page Library with SQLite FTS5 Search** | — | **Included** | **Included** | +| **Offline PDF Input & Document Extraction** | — | **Included** | **Included** | +| **Custom Prompt Presets** (Legal, Journalism, Clinical + Custom) | — | **Included** | **Custom-authored for team** | +| **Private Cloud Mode** (Phone as AI Server for Laptop via Tailscale) | — | **Included** | **Tailnet ACL & fleet setup** | + +--- + +## Prices + +| Offer | Price | Checkout | +|---|---|---| +| **Nullcloud Pro license** | €19 one-time | [Get Pro](https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575) | +| **Remote setup** | €30 | [Remote setup](https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4) | +| **Pro + setup bundle** | €45 | [Pro + setup](https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52) | +| **Tailscale private cloud** | €60-80 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | +| **Team consulting** | from €250 | Email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) for a quote | + +Pay via the link, then email [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) with the payment confirmation. The license key is sent manually. + +## How Offline Pro Licensing Works (Zero Phone-Home) + +Nullcloud Pro uses **100% offline ECDSA P-256 cryptographic signatures**: + +1. **Purchase:** The buyer pays with one of the Revolut links above. +2. **Confirmation:** The buyer emails [Pozun.alen96@gmail.com](mailto:Pozun.alen96@gmail.com) with the payment confirmation. +3. **Key Issuance:** The maintainer runs `node tools/license/sign.mjs --ref ORDER-1234 --sku pro` with the offline private key (`private.pem`, never committed) and emails this key: + ```text + NULLCLOUD-. + ``` +4. **Offline Verification:** The customer pastes the key into the Nullcloud Web UI (`http://127.0.0.1:8080` -> **Private Cloud & Pro**), the Browser Extension, or `~/.nullcloud-pro.key`. +5. `server/server.py` verifies the ECDSA P-256 signature locally using pure Python stdlib math against `DEFAULT_LICENSE_PUBKEY`. No network call is ever made. + +### Issuing & Verifying Keys (Maintainer CLI) + +Generate the keypair on your own computer. `tools/license/keys/private.pem` is gitignored. Never commit it, never email it, and keep an offline backup (mode `0600`). + +The public key shipped in this repository only verifies keys signed by its matching private key. If you do not already hold that private key, run keygen once, then replace `DEFAULT_LICENSE_PUBKEY` in `server/server.py` and `tools/license/keys/public.b64` with the new public key before issuing customer keys. + +```bash +# 1. Generate your P-256 signing keypair (keep private.pem secret and offline) +node tools/license/keygen.mjs + +# 2. Paste the printed public key into server/server.py as DEFAULT_LICENSE_PUBKEY +# and into tools/license/keys/public.b64. Commit only those public files. + +# 3. After the buyer emails payment confirmation, issue one key for that order +node tools/license/sign.mjs --ref ORDER-1001 --sku pro + +# 4. Verify the key offline, then email only the NULLCLOUD-... line to the buyer +node tools/license/verify.mjs 'NULLCLOUD-...' +``` + +--- + +## Selling the "Private Cloud" (Phone -> Laptop over Tailscale) + +With Nullcloud Pro, a user's Android phone becomes a personal, portable AI server that they can reach from their laptop or iPad anywhere in the world—without paying for cloud tokens or exposing a port on public Wi-Fi: + +1. Start Nullcloud on the phone with Tailscale binding enabled: + ```bash + TAILSCALE=1 bash ~/nullcloud/termux/start.sh + ``` +2. Nullcloud detects the phone's `100.x.y.z` Tailscale IPv4 address and binds the token-protected API to `http://100.x.y.z:8080` while keeping `llama-server` strictly on `127.0.0.1:8081`. +3. On the laptop, open the Nullcloud Browser Extension -> **CPU / GPU, Private Cloud & Pro Settings** -> enter `http://100.x.y.z:8080` and the token. Every click on the laptop now streams answers from the phone's GPU/NPU/CPU. diff --git a/extension/icons/128.png b/extension/icons/128.png new file mode 100644 index 0000000000000000000000000000000000000000..b774592079128d09bf6c3868d2c22277dc74cd21 GIT binary patch literal 1096 zcmV-O1h@N%P) z!IjfM5JX#koZ(0g1VsV_z=;zl0w946$~pLG0ss!YS+urf&uC`4tGnwT_pL2W*H=lh zz1~F>`S9`6AIT7l1-BU3?HA?jH(4-_}~3LtHm3t)W&Sc0&2a{)v!2B8hgnjr)b!5N4KNNa@=K%{>#8nd;fkRtP?)xN3kkXl>vv_<~yj3f3Zc4Zh%BfP(se+Hf9xVG$9DB0*u6 z3{gTaTrcM(qySsAN)avWEm8?U?zvvhi>uo}GMQ-X9FaM0wm{E0G&-m?Pdc|f_Us@%2Pmcb_HMy#Nknv4;z3I_~%)M zPfr1qz%Mn~Rb2y60)NzIR(%mj3H;EKRnuJnCBUaOqvqQ{N`T9eY`VhaN69egIVihc z2x1%9)0@Gt0NDn*j5_#+AHfu1E~8s_YK2pVG0Z&Gs-D9sL>-#fR(}B}LUH#RA)@yH O0000NMx0r4JSNA$UG&a>gl*JGxz99RUYTH$Yl-Bv1PD|RQPJe95 za+IOu?2Q`|afe^&2^~-{W(W^?7~K4jsirMqa`M5Y6Z$=x*&X8?o=uoMmr>%q?r9wb R!yKU944$rjF6*2UngH=7JURdX literal 0 HcmV?d00001 diff --git a/extension/icons/48.png b/extension/icons/48.png new file mode 100644 index 0000000000000000000000000000000000000000..c3dbf4e77c3c446d1255f12752d0387c4038c420 GIT binary patch literal 399 zcmV;A0dW3_P)*DXUJdo}c9;sB%4ejr!3sE4mk~!VScM0=+1yYEYod&kb+ai8i`s1vG5u(f(>ZEYs3mNpa~xv zQ*0mwd`xVSft2u}IaY}{@H!m-ey^{WBzf2skcC2P!ea70Up3L^yz4 z6WpBHzswZ_O>yot+IOQ;fvHstOf8lH?Z1JVIhRoZfHxvqPhv^Mms0^?oV6g$A+A75 z6CtsKrceqKp)rHkfToF + + +

Nullcloud on-device, no cloud AIMIT Core

+ + + + +
+ CPU / GPU, Private Cloud & Pro Settings + + + +
+ +
Select text on the page to analyze only that selection, or click Run for the full page.
+
+ Get Pro — €19 one-time + Remote setup — €30 + Pro + setup — €45 +

Pay via the link, then email Pozun.alen96@gmail.com with the payment confirmation. The license key is sent manually. Tailscale private cloud (€60-80) and team consulting (from €250): contact Pozun.alen96@gmail.com for a quote.

+
+
+ + + +
+ + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..8080c9b --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,132 @@ +const $ = (id) => document.getElementById(id); +const DEFAULT_API = "http://127.0.0.1:8080"; +const store = chrome.storage.local; + +function toggleAsk() { + $("q").style.display = $("mode").value === "ask" ? "block" : "none"; +} + +function getApiUrl() { + return ($("api").value.trim() || DEFAULT_API).replace(/\/+$/, ""); +} + +async function loadProPresets(api, token, license) { + if (!token) return; + try { + const headers = { "X-Token": token }; + if (license) headers["X-License-Key"] = license; + const r = await fetch(api + "/presets", { headers }); + if (!r.ok) return; + const d = await r.json(); + if (d.pro) { + $("pro-pill").textContent = "Pro Unlocked"; + const existing = new Set(Array.from($("mode").options).map((o) => o.value)); + (d.presets || []).forEach((p) => { + const val = "preset:" + p.id; + if (!existing.has(val)) { + const opt = document.createElement("option"); + opt.value = val; + opt.textContent = "[Pro] " + p.name; + $("mode").appendChild(opt); + } + }); + } + } catch (_) {} +} + +(async () => { + const s = await store.get(["token", "lang", "mode", "api", "license", "gpu"]); + $("token").value = s.token || ""; + $("lang").value = s.lang || ""; + $("mode").value = s.mode || "summary"; + $("api").value = s.api || DEFAULT_API; + $("license").value = s.license || ""; + $("gpu").value = s.gpu || "99"; + toggleAsk(); + loadProPresets(getApiUrl(), $("token").value.trim(), $("license").value.trim()); +})(); + +$("mode").addEventListener("change", toggleAsk); +$("copy").addEventListener("click", () => navigator.clipboard.writeText($("out").textContent)); +$("open-ui").addEventListener("click", () => chrome.tabs.create({ url: getApiUrl() })); + +$("go").addEventListener("click", async () => { + const out = $("out"); + const token = $("token").value.trim(); + const lang = $("lang").value.trim(); + const mode = $("mode").value; + const api = getApiUrl(); + const license = $("license").value.trim(); + const gpu = $("gpu").value || "99"; + await store.set({ token, lang, mode, api, license, gpu }); + if (!token) { + out.textContent = "Paste your token first (printed by setup.sh / start.sh)."; + return; + } + out.textContent = "Reading open page (offline-capable)..."; + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const [res] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => ({ + sel: String(getSelection()).trim(), + body: document.body ? document.body.innerText : "", + title: document.title, + }), + }); + const page = res.result; + const text = "Title: " + page.title + "\n\n" + (page.sel.length > 40 ? page.sel : page.body); + out.textContent = "Thinking (on-device CPU + GPU, no cloud AI)..."; + const headers = { "Content-Type": "application/json", "X-Token": token }; + if (license) headers["X-License-Key"] = license; + const r = await fetch(api + "/analyze", { + method: "POST", + headers, + body: JSON.stringify({ + text, + title: page.title, + mode, + q: $("q").value, + lang, + url: tab.url, + gpu_layers: Number(gpu), + }), + }); + if (r.status === 401) { + out.textContent = "Wrong token."; + return; + } + if (!r.ok) { + const err = await r.json().catch(() => ({})); + if (r.status === 402) { + out.textContent = + (err.error || "Nullcloud Pro is required.") + + " Use Get Pro, Remote setup, or Pro + setup below. Pay via the link, then email Pozun.alen96@gmail.com with the payment confirmation. The license key is sent manually."; + return; + } + out.textContent = err.error || "Error " + r.status; + return; + } + const reader = r.body.getReader(); + const dec = new TextDecoder(); + out.textContent = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + out.textContent += dec.decode(value, { stream: true }); + } + } catch (e) { + out.textContent = + "Cannot reach the local/Tailscale server (" + + e.message + + "). Is it running? See README > Troubleshooting."; + } +}); + +$("speak").addEventListener("click", () => { + if (speechSynthesis.speaking) { + speechSynthesis.cancel(); + return; + } + speechSynthesis.speak(new SpeechSynthesisUtterance($("out").textContent)); +}); diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..6b45f51 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +flask-cors>=4.0 +requests>=2.31 diff --git a/server/server.py b/server/server.py new file mode 100644 index 0000000..27e15c9 --- /dev/null +++ b/server/server.py @@ -0,0 +1,1070 @@ +#!/usr/bin/env python3 +"""Nullcloud server: page text, URL, or PDF -> local LLM (OpenAI-compatible API) -> streamed answer. +On-device, no cloud AI. Works in Airplane Mode on open pages, local text, cached pages, and PDFs. +Supports llama.cpp's llama-server (Termux/desktop) and Ollama (desktop). + +Free Core (MIT): +- 3 entry points: Android Share Sheet, Web UI, Browser Extension +- Built-in modes: summary, facts, claims, eli5, translate, ask +- Airplane Mode / offline text & cached page analysis +- Read aloud (TTS) & opt-in Markdown / Obsidian vault export + +Pro Productivity Hub & Private Cloud (one-time offline ECDSA P-256 license, paid via Revolut): +- Saved-page library with SQLite FTS5 full-text search +- PDF input extraction (offline via pdftotext, pypdf, or built-in stream parser) +- Custom prompt presets (including high-compliance templates for law, journalism, and clinics) +- Private Cloud server over Tailscale (use your phone as a private AI server from your laptop) +""" +import base64 +import datetime +import hashlib +import hmac +import ipaddress +import json +import os +import pathlib +import re +import shutil +import socket +import sqlite3 +import subprocess +import sys +import tempfile +import zlib +from html.parser import HTMLParser +from urllib.parse import urljoin, urlparse + +import requests +from flask import Flask, Response, jsonify, request, send_from_directory +from flask_cors import CORS + +HOST = os.environ.get("HOST", "127.0.0.1") +PORT = int(os.environ.get("PORT", "8080")) +LLM_URL = os.environ.get("LLM_URL", "http://127.0.0.1:8081/v1/chat/completions") +MODEL = os.environ.get("MODEL", "local") +TOKEN = os.environ.get("TOKEN", "") +MAX_CHARS = int(os.environ.get("MAX_CHARS", "6000")) +MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "400")) +CTX_SIZE = int(os.environ.get("CTX_SIZE", "3072")) +CPU_THREADS = int(os.environ.get("CPU_THREADS", str(min(os.cpu_count() or 4, 6)))) +GPU_LAYERS = int(os.environ.get("GPU_LAYERS", "99")) +GPU_NAME = os.environ.get("GPU_NAME", "") +ACCEL_MODE = os.environ.get("ACCEL_MODE", "") +KV_CACHE = os.environ.get("KV_CACHE", "q8_0/q4_0") +FLASH_ATTN = os.environ.get("FLASH_ATTN", "1") != "0" +NOTES_DIR = os.environ.get("NOTES_DIR", "") # opt-in: save results as Markdown (e.g. an Obsidian vault folder) +ARCHIVE = os.environ.get("ARCHIVE", "") == "1" # opt-in: also keep the raw HTML next to each note + +HOME_DIR = pathlib.Path(os.environ.get("NULLCLOUD_HOME", str(pathlib.Path.home()))) +LICENSE_FILE = pathlib.Path(os.environ.get("NULLCLOUD_LICENSE_FILE", str(HOME_DIR / ".nullcloud-pro.key"))) +LIBRARY_DB = pathlib.Path(os.environ.get("NULLCLOUD_LIBRARY_DB", str(HOME_DIR / ".nullcloud-library.db"))) +PRESETS_FILE = pathlib.Path(os.environ.get("NULLCLOUD_PRESETS_FILE", str(HOME_DIR / ".nullcloud-presets.json"))) +LAST_RESULT_FILE = pathlib.Path(os.environ.get("NULLCLOUD_LAST_FILE", str(HOME_DIR / ".nullcloud-last.json"))) +COMPUTE_FILE = pathlib.Path(os.environ.get("NULLCLOUD_COMPUTE_FILE", str(HOME_DIR / ".nullcloud-compute.json"))) + +# Default P-256 SPKI public key for offline Pro license verification (override via NULLCLOUD_PUBKEY for custom builds) +DEFAULT_LICENSE_PUBKEY = ( + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuc4zK4+ZGpUSY/Qxb6RoRgim4UeP" + "W7GlkvdEmZ5rIapjL/7xQh9Ja38fEt2yx1M5t2l+R+6WmiUf0bZuhfkF2A==" +) +LICENSE_PUBKEY = os.environ.get("NULLCLOUD_PUBKEY", DEFAULT_LICENSE_PUBKEY) + +if not TOKEN and os.environ.get("NULLCLOUD_TESTING") != "1": + sys.exit("Set TOKEN first, e.g.: TOKEN=$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \\n') python server.py") + +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = int(os.environ.get("MAX_UPLOAD_BYTES", str(16 * 1024 * 1024))) +CORS( + app, + origins=[ + r"^chrome-extension://.*$", + r"^moz-extension://.*$", + r"^http://127\.0\.0\.1(:\d+)?$", + r"^http://localhost(:\d+)?$", + r"^https?://100\.\d+\.\d+\.\d+(:\d+)?$", + r"^https?://[a-zA-Z0-9._-]+\.ts\.net(:\d+)?$", + ], +) + +MODES = { + "summary": "Summarize the page in 3-5 concise bullet points, then one sentence on why it matters.", + "facts": "List the key facts, numbers, names and dates in the page as bullets. Add nothing that is not in the page.", + "claims": "List the main claims the page makes. For each, say whether the page itself gives evidence for it. Stay neutral.", + "translate": "Translate the main content of the page into {lang}.", + "eli5": "Explain the page in simple words a 12-year-old would understand, in at most 6 sentences.", +} + +DEFAULT_PRO_PRESETS = [ + { + "id": "legal-privilege", + "name": "Legal: Privilege & Risk Scan", + "category": "Legal / Law Firm", + "prompt": ( + "Review the document strictly on-device. List: (1) Parties, dates, and governing obligations; " + "(2) Potential liability, indemnity, or confidentiality clauses; (3) Ambiguities or missing deadlines. " + "Cite exact phrases from the text." + ), + }, + { + "id": "journalism-source", + "name": "Journalism: Source Verification & Chronology", + "category": "Journalism / Newsroom", + "prompt": ( + "Extract a strict chronological timeline of events, named sources, direct quotes, and quantitative claims. " + "Flag any unattributed assertions or contradictions within the text." + ), + }, + { + "id": "clinical-intake", + "name": "Clinical: De-Identified Summary & Key Findings", + "category": "Medical / Clinic", + "prompt": ( + "Summarize the clinical note or research paper into: Chief Complaint / Objective, Key Findings & Vitals, " + "Medications / Interventions mentioned, and Follow-up items. Do not invent any clinical details." + ), + }, + { + "id": "exec-brief", + "name": "Executive: 60-Second Action Brief", + "category": "Productivity", + "prompt": ( + "Provide a 3-bullet executive summary, the single most important takeaway, and up to 3 concrete action items." + ), + }, +] + +# --- Pure-Python NIST P-256 ECDSA Verification (Zero extra dependencies, 100% offline) --- +_P256_P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF +_P256_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 +_P256_A = _P256_P - 3 +_P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296 +_P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5 + + +def _pt_add(p1, p2): + if p1 is None: + return p2 + if p2 is None: + return p1 + x1, y1 = p1 + x2, y2 = p2 + if x1 == x2 and y1 != y2: + return None + if x1 == x2: + if y1 == 0: + return None + m = (3 * x1 * x1 + _P256_A) * pow(2 * y1, _P256_P - 2, _P256_P) % _P256_P + else: + m = (y2 - y1) * pow(x2 - x1, _P256_P - 2, _P256_P) % _P256_P + x3 = (m * m - x1 - x2) % _P256_P + y3 = (m * (x1 - x3) - y1) % _P256_P + return (x3, y3) + + +def _pt_mul(k, p): + r = None + a = p + while k > 0: + if k & 1: + r = _pt_add(r, a) + a = _pt_add(a, a) + k >>= 1 + return r + + +def _b64u_decode(s): + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def _parse_der_sig(der): + if len(der) < 8 or der[0] != 0x30: + raise ValueError("Invalid DER sequence") + idx = 2 if der[1] < 0x80 else 2 + (der[1] & 0x7F) + if der[idx] != 0x02: + raise ValueError("Invalid DER r integer") + rlen = der[idx + 1] + r = int.from_bytes(der[idx + 2 : idx + 2 + rlen], "big") + idx += 2 + rlen + if der[idx] != 0x02: + raise ValueError("Invalid DER s integer") + slen = der[idx + 1] + s = int.from_bytes(der[idx + 2 : idx + 2 + slen], "big") + return r, s + + +def verify_license_key(key_str, pubkey_b64=None): + """Verify an offline NULLCLOUD-. ECDSA P-256 key. Never makes network calls.""" + if not key_str or not isinstance(key_str, str): + return False, None + k = key_str.strip() + for prefix in ("NULLCLOUD-", "MERIDIAN-"): + if k.startswith(prefix): + k = k[len(prefix) :] + break + if "." not in k: + return False, None + try: + p_b64, s_b64 = k.split(".", 1) + payload = _b64u_decode(p_b64) + sig_der = _b64u_decode(s_b64) + spki = base64.b64decode((pubkey_b64 or LICENSE_PUBKEY).strip()) + pub_point = spki[-65:] + if len(pub_point) != 65 or pub_point[0] != 0x04: + return False, None + qx = int.from_bytes(pub_point[1:33], "big") + qy = int.from_bytes(pub_point[33:65], "big") + r, s = _parse_der_sig(sig_der) + if not (1 <= r < _P256_N and 1 <= s < _P256_N): + return False, None + z = int.from_bytes(hashlib.sha256(payload).digest(), "big") + w = pow(s, _P256_N - 2, _P256_N) + u1 = (z * w) % _P256_N + u2 = (r * w) % _P256_N + pt = _pt_add(_pt_mul(u1, (_P256_GX, _P256_GY)), _pt_mul(u2, (qx, qy))) + if pt is None or (pt[0] % _P256_N) != r: + return False, None + claims = json.loads(payload.decode("utf-8")) + if claims.get("sku") not in ("pro", "team", "premium"): + return False, None + return True, claims + except Exception: # noqa: BLE001 + return False, None + + +def get_pro_status(): + """Check if Pro tier is unlocked via request header, env var, or saved ~/.nullcloud-pro.key.""" + candidates = [ + request.headers.get("X-License-Key", "") if request else "", + os.environ.get("NULLCLOUD_LICENSE_KEY", ""), + ] + if LICENSE_FILE.is_file(): + try: + candidates.append(LICENSE_FILE.read_text(encoding="utf-8").strip()) + except Exception: # noqa: BLE001 + pass + for c in candidates: + if c: + ok, claims = verify_license_key(c) + if ok: + return True, claims + return False, None + + +# --- HTML Parser & SSRF-Safe Fetcher --- +class _Text(HTMLParser): + SKIP = {"script", "style", "noscript", "svg", "head", "nav", "footer", "iframe", "form", "aside"} + BREAK = {"p", "div", "br", "li", "h1", "h2", "h3", "h4", "tr", "section", "article", "blockquote"} + + def __init__(self): + super().__init__() + self.out, self.skip = [], 0 + self.in_title = False + self.title_parts = [] + + def handle_starttag(self, tag, attrs): + if tag == "title": + self.in_title = True + if tag in self.SKIP: + self.skip += 1 + if tag in self.BREAK: + self.out.append("\n") + + def handle_endtag(self, tag): + if tag == "title": + self.in_title = False + if tag in self.SKIP and self.skip: + self.skip -= 1 + if tag in self.BREAK: + self.out.append("\n") + + def handle_data(self, data): + if self.in_title and data.strip(): + self.title_parts.append(data.strip()) + if not self.skip and data.strip(): + self.out.append(data.strip() + " ") + + @property + def title(self): + return " ".join(self.title_parts).strip() + + +def validate_public_url(url): + p = urlparse(url) + if p.scheme not in ("http", "https") or not p.hostname: + raise ValueError("Only http/https URLs are supported.") + host = p.hostname.lower().rstrip(".") + if host in ("localhost", "0.0.0.0") or host.endswith(".local") or host.endswith(".internal"): + raise ValueError("Private/local addresses are blocked.") + for info in socket.getaddrinfo(host, None): + ip = ipaddress.ip_address(info[4][0]) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + or (getattr(ip, "ipv4_mapped", None) is not None and (ip.ipv4_mapped.is_private or ip.ipv4_mapped.is_loopback)) + ): + raise ValueError("Private/local addresses are blocked.") + return url + + +def find_cached_archive(url): + """Look up previously archived HTML or saved library page for Airplane Mode / offline use.""" + if LIBRARY_DB.is_file(): + try: + with sqlite3.connect(LIBRARY_DB) as conn: + row = conn.execute( + "SELECT content, title FROM pages WHERE url = ? ORDER BY id DESC LIMIT 1", (url,) + ).fetchone() + if row and row[0]: + return row[0], "", row[1] or "" + except Exception: # noqa: BLE001 + pass + if NOTES_DIR: + base = pathlib.Path(NOTES_DIR).expanduser() + if base.is_dir(): + for md_file in sorted(base.glob("*.md"), reverse=True): + try: + content = md_file.read_text(encoding="utf-8", errors="ignore") + if f'source: "{url}"' in content: + m = re.search(r"^archive:\s*(.+)$", content, re.M) + if m: + arch_path = (base / m.group(1).strip()).resolve() + if str(arch_path).startswith(str(base.resolve())) and arch_path.is_file(): + raw_html = arch_path.read_text(encoding="utf-8", errors="ignore") + parser = _Text() + parser.feed(raw_html[:2_000_000]) + text = re.sub(r"\n\s*\n+", "\n", "".join(parser.out)).strip() + return text, raw_html, parser.title + except Exception: # noqa: BLE001 + continue + return None + + +def fetch_text_with_meta(url): + current = url + r = None + try: + for _ in range(5): + validate_public_url(current) + r = requests.get( + current, + timeout=20, + allow_redirects=False, + headers={"User-Agent": "Mozilla/5.0 (Nullcloud; on-device AI)"}, + ) + if r.is_redirect or r.status_code in (301, 302, 303, 307, 308): + loc = r.headers.get("Location") + if not loc: + break + current = urljoin(current, loc) + continue + break + else: + raise ValueError("Too many redirects.") + r.raise_for_status() + raw_html = r.text[:2_000_000] + parser = _Text() + parser.feed(raw_html) + cleaned = re.sub(r"\n\s*\n+", "\n", "".join(parser.out)).strip() + return cleaned, raw_html, parser.title + except requests.RequestException as net_err: + cached = find_cached_archive(url) + if cached: + return cached + raise ValueError( + f"{net_err} (If your phone is in Airplane Mode, analyze an already-open tab via the extension, " + "paste the text directly, or open a page saved in your offline Library.)" + ) from net_err + + +def fetch_text(url): + cleaned, raw_html, _ = fetch_text_with_meta(url) + return cleaned, raw_html + + +# --- Offline PDF Extraction (Pro feature) --- +def _extract_pdf_bytes_fallback(pdf_bytes): + """Lightweight pure-Python PDF text extractor for simple/uncompressed or FlateDecode PDF streams.""" + chunks = [] + # Scan streams (both compressed FlateDecode and raw) + for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.S): + stream_data = m.group(1) + decoded = None + for wbits in (zlib.MAX_WBITS, -zlib.MAX_WBITS): + try: + decoded = zlib.decompress(stream_data, wbits) + break + except Exception: # noqa: BLE001 + pass + data = decoded if decoded is not None else stream_data + # Extract literal strings inside parentheses (...) or Tj/TJ blocks + for sm in re.finditer(rb"\(([^()\\]*(?:\\.[^()\\]*)*)\)", data): + raw_s = sm.group(1) + raw_s = ( + raw_s.replace(rb"\n", b"\n") + .replace(rb"\r", b" ") + .replace(rb"\t", b" ") + .replace(rb"\(", b"(") + .replace(rb"\)", b")") + .replace(rb"\\", b"\\") + ) + txt = raw_s.decode("latin-1", errors="ignore").strip() + if txt and any(c.isalpha() for c in txt): + chunks.append(txt) + if not chunks: + # Also check top-level literal strings if streams weren't matched + for sm in re.finditer(rb"\(([^()\\]*(?:\\.[^()\\]*)*)\)\s*Tj", pdf_bytes): + txt = sm.group(1).decode("latin-1", errors="ignore").strip() + if txt: + chunks.append(txt) + return " ".join(chunks).strip() + + +def extract_pdf_text(pdf_bytes, max_pages=30): + """Extract text from PDF bytes completely on-device (tries pypdf -> pdftotext -> stdlib fallback).""" + if not pdf_bytes or not pdf_bytes.startswith(b"%PDF"): + raise ValueError("Invalid PDF file (missing %PDF header).") + # 1. Try pypdf / PyPDF2 if installed + for mod_name in ("pypdf", "PyPDF2"): + try: + mod = __import__(mod_name) + import io + + reader = mod.PdfReader(io.BytesIO(pdf_bytes)) + pages = [p.extract_text() or "" for p in reader.pages[:max_pages]] + text = "\n\n".join(p.strip() for p in pages if p.strip()).strip() + if text: + return text + except Exception: # noqa: BLE001 + pass + # 2. Try pdftotext (poppler in Termux / Linux) + if shutil.which("pdftotext"): + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as tmp: + tmp.write(pdf_bytes) + tmp.flush() + res = subprocess.run( + ["pdftotext", "-l", str(max_pages), tmp.name, "-"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if res.returncode == 0 and res.stdout.strip(): + return res.stdout.strip() + # 3. Pure-Python fallback + text = _extract_pdf_bytes_fallback(pdf_bytes) + if text: + return text + raise ValueError("Could not extract readable text from PDF. Install poppler (`pkg install poppler`) or pypdf.") + + +# --- Markdown Notes & Pro SQLite FTS5 Library --- +def save_note(url, mode, output, raw): + """Write a Markdown note (+ optional raw HTML archive). Local files only; never raises.""" + if not NOTES_DIR: + return + try: + base = pathlib.Path(NOTES_DIR).expanduser().resolve() + base.mkdir(parents=True, exist_ok=True) + now = datetime.datetime.now() + host = re.sub(r"[^a-z0-9]+", "-", (urlparse(url).hostname or "note").lower()).strip("-") or "note" + name = f"{now:%Y-%m-%d-%H%M%S}-{host}" + meta = [ + f'source: "{url}"', + f"date: {now.isoformat(timespec='seconds')}", + f"mode: {mode}", + "tags: [nullcloud]", + ] + if ARCHIVE and raw: + arch_dir = (base / "archive").resolve() + arch_dir.mkdir(exist_ok=True) + (arch_dir / f"{name}.html").write_text(raw, encoding="utf-8") + meta.append(f"archive: archive/{name}.html") + (base / f"{name}.md").write_text( + "---\n" + "\n".join(meta) + "\n---\n\n" + output.strip() + "\n", encoding="utf-8" + ) + except Exception as e: # noqa: BLE001 + print("save_note failed:", e, file=sys.stderr) + + +def _init_library_db(): + LIBRARY_DB.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(LIBRARY_DB) as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT, + title TEXT, + mode TEXT, + content TEXT, + summary TEXT, + created_at TEXT + ) + """ + ) + try: + conn.execute( + """ + CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5( + title, url, mode, content, summary, content='pages', content_rowid='id' + ) + """ + ) + except sqlite3.OperationalError: + pass + + +def save_to_library(url, title, mode, content, summary): + """Save analyzed page + summary into the local SQLite library.""" + try: + _init_library_db() + now = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + clean_title = (title or "").strip() or (urlparse(url).hostname if url else "On-device note") or "On-device note" + with sqlite3.connect(LIBRARY_DB) as conn: + cur = conn.execute( + "INSERT INTO pages (url, title, mode, content, summary, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (url or "", clean_title, mode, (content or "")[:MAX_CHARS], (summary or "").strip(), now), + ) + rowid = cur.lastrowid + try: + conn.execute( + "INSERT INTO pages_fts(rowid, title, url, mode, content, summary) VALUES (?, ?, ?, ?, ?, ?)", + (rowid, clean_title, url or "", mode, (content or "")[:MAX_CHARS], (summary or "").strip()), + ) + except sqlite3.OperationalError: + pass + return rowid + except Exception as e: # noqa: BLE001 + print("save_to_library failed:", e, file=sys.stderr) + return None + + +def search_library(query_str="", limit=50): + _init_library_db() + q = (query_str or "").strip() + with sqlite3.connect(LIBRARY_DB) as conn: + conn.row_factory = sqlite3.Row + if not q: + rows = conn.execute( + "SELECT id, url, title, mode, content, summary, created_at FROM pages ORDER BY id DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + try: + safe_fts = " ".join(f'"{w}"' for w in re.findall(r"\w+", q)) + if safe_fts: + rows = conn.execute( + """ + SELECT p.id, p.url, p.title, p.mode, p.content, p.summary, p.created_at + FROM pages_fts f JOIN pages p ON p.id = f.rowid + WHERE pages_fts MATCH ? + ORDER BY rank LIMIT ? + """, + (safe_fts, limit), + ).fetchall() + return [dict(r) for r in rows] + except sqlite3.OperationalError: + pass + like = f"%{q}%" + rows = conn.execute( + """ + SELECT id, url, title, mode, content, summary, created_at FROM pages + WHERE title LIKE ? OR url LIKE ? OR content LIKE ? OR summary LIKE ? + ORDER BY id DESC LIMIT ? + """, + (like, like, like, like, limit), + ).fetchall() + return [dict(r) for r in rows] + + +def load_presets(): + custom = [] + if PRESETS_FILE.is_file(): + try: + data = json.loads(PRESETS_FILE.read_text(encoding="utf-8")) + if isinstance(data, list): + custom = data + except Exception: # noqa: BLE001 + pass + return DEFAULT_PRO_PRESETS + custom + + +def save_custom_presets(custom_list): + PRESETS_FILE.parent.mkdir(parents=True, exist_ok=True) + PRESETS_FILE.write_text(json.dumps(custom_list, indent=2), encoding="utf-8") + + +def detect_gpu_name(): + """Detect mobile or desktop GPU hardware (Adreno, Mali, Vulkan, Metal, CUDA, ROCm) without network calls.""" + if GPU_NAME: + return GPU_NAME + kgsl = pathlib.Path("/sys/class/kgsl/kgsl-3d0/gpu_model") + if kgsl.is_file(): + try: + return "Adreno " + kgsl.read_text(encoding="utf-8", errors="ignore").strip() + except Exception: # noqa: BLE001 + pass + if pathlib.Path("/sys/class/misc/mali0").exists() or pathlib.Path("/dev/mali0").exists(): + return "Arm Mali / Immortalis GPU" + if sys.platform == "darwin": + return "Apple Silicon Metal GPU" + if shutil.which("nvidia-smi"): + return "NVIDIA CUDA GPU" + if shutil.which("rocminfo"): + return "AMD ROCm GPU" + return "Vulkan / OpenCL GPU" + + +def get_compute_config(): + """Return active CPU + GPU acceleration settings (combining env defaults and user overrides).""" + cfg = { + "cpu_cores": os.cpu_count() or 4, + "cpu_threads": CPU_THREADS, + "cpu_isa": "ARM64 NEON / dotprod (big.LITTLE)" if os.uname().machine in ("aarch64", "arm64") else f"{os.uname().machine} SIMD/AVX2", + "gpu_name": detect_gpu_name(), + "gpu_layers": GPU_LAYERS, + "gpu_backends": ["Vulkan", "OpenCL", "Metal", "CUDA", "ROCm"], + "ctx_size": CTX_SIZE, + "kv_cache": KV_CACHE, + "flash_attn": FLASH_ATTN, + } + if COMPUTE_FILE.is_file(): + try: + saved = json.loads(COMPUTE_FILE.read_text(encoding="utf-8")) + if isinstance(saved, dict): + if "cpu_threads" in saved: + cfg["cpu_threads"] = max(1, min(int(saved["cpu_threads"]), 32)) + if "gpu_layers" in saved: + cfg["gpu_layers"] = max(0, min(int(saved["gpu_layers"]), 999)) + if "flash_attn" in saved: + cfg["flash_attn"] = bool(saved["flash_attn"]) + if "kv_cache" in saved and saved["kv_cache"] in ("q8_0/q4_0", "q8_0", "q4_0", "f16"): + cfg["kv_cache"] = saved["kv_cache"] + except Exception: # noqa: BLE001 + pass + mode_label = ( + f"CPU ({cfg['cpu_threads']} threads) + GPU ({cfg['gpu_name']}, -ngl {cfg['gpu_layers']}, KV {cfg['kv_cache']})" + if cfg["gpu_layers"] > 0 + else f"CPU-Only ({cfg['cpu_threads']} threads, {cfg['cpu_isa']}, KV {cfg['kv_cache']})" + ) + cfg["accel_summary"] = ACCEL_MODE or mode_label + return cfg + + +def get_tailscale_info(): + """Detect local Tailscale IPv4 (100.x.y.z) for Private Cloud mode.""" + ts_ip = None + if shutil.which("tailscale"): + try: + res = subprocess.run( + ["tailscale", "ip", "-4"], + capture_output=True, + text=True, + timeout=3, + check=False, + ) + if res.returncode == 0 and res.stdout.strip(): + ts_ip = res.stdout.strip().splitlines()[0].strip() + except Exception: # noqa: BLE001 + pass + if not ts_ip: + try: + for info in socket.getaddrinfo(socket.gethostname(), None): + ip_str = info[4][0] + ip_obj = ipaddress.ip_address(ip_str) + if ip_obj.version == 4 and ip_obj in ipaddress.ip_network("100.64.0.0/10"): + ts_ip = ip_str + break + except Exception: # noqa: BLE001 + pass + return { + "tailscale_ip": ts_ip, + "listening_host": HOST, + "port": PORT, + "private_cloud_url": f"http://{ts_ip}:{PORT}" if ts_ip else None, + "bind_command_termux": "TAILSCALE=1 bash ~/nullcloud/termux/start.sh", + "bind_command_desktop": f"HOST={ts_ip or ''} bash desktop/start.sh", + } + + +def authorized(): + provided = request.headers.get("X-Token", "") + if not TOKEN or not provided: + return False + return hmac.compare_digest(provided.encode("utf-8"), TOKEN.encode("utf-8")) + + +PRO_CHECKOUT_URL = "https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575" +SETUP_CHECKOUT_URL = "https://checkout.revolut.com/pay/ee0aad48-1ff6-452b-9470-01ae6db706a4" +BUNDLE_CHECKOUT_URL = "https://checkout.revolut.com/pay/c60fa94d-e094-4c1f-aac4-ae04c4776c52" +QUOTE_EMAIL = "Pozun.alen96@gmail.com" +PAYMENT_FLOW = ( + "Pay via the link, then email Pozun.alen96@gmail.com with the payment confirmation. " + "The license key is sent manually." +) +PAYMENT_OFFERS = [ + {"id": "pro", "label": "Get Pro", "price": "€19 one-time", "url": PRO_CHECKOUT_URL}, + {"id": "setup", "label": "Remote setup", "price": "€30", "url": SETUP_CHECKOUT_URL}, + {"id": "bundle", "label": "Pro + setup", "price": "€45", "url": BUNDLE_CHECKOUT_URL}, +] + + +def pro_locked_body(message): + return { + "error": message, + "pro_required": True, + "offers": PAYMENT_OFFERS, + "payment_flow": PAYMENT_FLOW, + "quote": ( + "Tailscale private cloud (€60-80) and team consulting (from €250): " + f"contact {QUOTE_EMAIL} for a quote." + ), + } + + +def require_pro(): + is_pro, _claims = get_pro_status() + if not is_pro: + return ( + jsonify( + **pro_locked_body( + "This feature requires Nullcloud Pro (saved-page library with search, PDF input, " + "custom prompt presets, and Private Cloud server over Tailscale). " + + PAYMENT_FLOW + ) + ), + 402, + ) + return None + + +# --- HTTP Routes --- +@app.get("/") +def ui(): + return send_from_directory(os.path.dirname(os.path.abspath(__file__)), "ui.html") + + +@app.get("/health") +def health(): + is_pro, claims = get_pro_status() + compute = get_compute_config() + return jsonify( + ok=True, + mode="on-device, no cloud AI", + offline_ready=True, + model=MODEL, + max_chars=MAX_CHARS, + max_tokens=MAX_TOKENS, + pro=is_pro, + sku=claims.get("sku") if claims else "free", + compute=compute, + ) + + +@app.get("/hardware") +def get_hardware(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + return jsonify(ok=True, compute=get_compute_config()) + + +@app.post("/hardware") +def update_hardware(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + d = request.get_json(force=True, silent=True) or {} + cfg = get_compute_config() + updated = { + "cpu_threads": max(1, min(int(d.get("cpu_threads", cfg["cpu_threads"])), 32)), + "gpu_layers": max(0, min(int(d.get("gpu_layers", cfg["gpu_layers"])), 999)), + "flash_attn": bool(d.get("flash_attn", cfg["flash_attn"])), + "kv_cache": str(d.get("kv_cache", cfg["kv_cache"])), + } + COMPUTE_FILE.parent.mkdir(parents=True, exist_ok=True) + COMPUTE_FILE.write_text(json.dumps(updated, indent=2), encoding="utf-8") + return jsonify(ok=True, compute=get_compute_config()) + + +@app.get("/last") +def last_result(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + if not LAST_RESULT_FILE.is_file(): + return jsonify(error="No recent analysis yet."), 404 + try: + return jsonify(json.loads(LAST_RESULT_FILE.read_text(encoding="utf-8"))) + except Exception as e: # noqa: BLE001 + return jsonify(error=str(e)), 500 + + +@app.get("/pro/status") +def pro_status(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + is_pro, claims = get_pro_status() + return jsonify( + pro=is_pro, + sku=claims.get("sku") if claims else "free", + claims=claims, + tailscale=get_tailscale_info() if is_pro else None, + ) + + +@app.post("/pro/activate") +def pro_activate(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + d = request.get_json(force=True, silent=True) or {} + key_str = (d.get("key") or "").strip() + ok, claims = verify_license_key(key_str) + if not ok: + return jsonify(error="Invalid Nullcloud Pro license key. Check that you copied the full NULLCLOUD-... key."), 400 + LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True) + LICENSE_FILE.write_text(key_str + "\n", encoding="utf-8") + try: + os.chmod(LICENSE_FILE, 0o600) + except OSError: + pass + return jsonify(ok=True, pro=True, claims=claims, tailscale=get_tailscale_info()) + + +@app.get("/presets") +def get_presets(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + is_pro, _ = get_pro_status() + if not is_pro: + return jsonify(presets=[], pro_required=True) + return jsonify(presets=load_presets(), pro=True) + + +@app.post("/presets") +def add_preset(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + gate = require_pro() + if gate: + return gate + d = request.get_json(force=True, silent=True) or {} + name = (d.get("name") or "").strip() + prompt = (d.get("prompt") or "").strip() + category = (d.get("category") or "Custom").strip() + if not name or not prompt: + return jsonify(error="Preset name and prompt are required."), 400 + preset_id = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "custom" + custom = [] + if PRESETS_FILE.is_file(): + try: + custom = json.loads(PRESETS_FILE.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + custom = [] + custom = [p for p in custom if p.get("id") != preset_id] + custom.append({"id": preset_id, "name": name[:80], "category": category[:40], "prompt": prompt[:2000]}) + save_custom_presets(custom) + return jsonify(ok=True, presets=load_presets()) + + +@app.delete("/presets/") +def delete_preset(preset_id): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + gate = require_pro() + if gate: + return gate + custom = [] + if PRESETS_FILE.is_file(): + try: + custom = json.loads(PRESETS_FILE.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + custom = [] + custom = [p for p in custom if p.get("id") != preset_id] + save_custom_presets(custom) + return jsonify(ok=True, presets=load_presets()) + + +@app.get("/library") +def list_library(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + gate = require_pro() + if gate: + return gate + q = request.args.get("q", "") + items = search_library(q) + return jsonify(items=items, count=len(items)) + + +@app.post("/library") +def create_library_item(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + gate = require_pro() + if gate: + return gate + d = request.get_json(force=True, silent=True) or {} + rowid = save_to_library( + url=d.get("url") or "", + title=d.get("title") or "", + mode=d.get("mode") or "summary", + content=d.get("content") or d.get("text") or "", + summary=d.get("summary") or "", + ) + return jsonify(ok=True, id=rowid) + + +@app.delete("/library/") +def delete_library_item(item_id): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + gate = require_pro() + if gate: + return gate + _init_library_db() + with sqlite3.connect(LIBRARY_DB) as conn: + conn.execute("DELETE FROM pages WHERE id = ?", (item_id,)) + try: + conn.execute("DELETE FROM pages_fts WHERE rowid = ?", (item_id,)) + except sqlite3.OperationalError: + pass + return jsonify(ok=True) + + +@app.post("/analyze") +def analyze(): + if not authorized(): + return jsonify(error="Wrong or missing token."), 401 + d = request.get_json(force=True, silent=True) or {} + is_pro, _ = get_pro_status() + + text = (d.get("text") or "").strip() + raw = "" + title = (d.get("title") or "").strip() + + # Pro feature: PDF input (base64 encoded PDF bytes) + if d.get("pdf_base64"): + if not is_pro: + return require_pro() + try: + pdf_bytes = base64.b64decode(d["pdf_base64"]) + text = extract_pdf_text(pdf_bytes) + if not title: + title = d.get("filename") or "PDF Document" + except Exception as e: # noqa: BLE001 + return jsonify(error=f"Could not read PDF: {e}"), 400 + + try: + if not text and d.get("url"): + text, raw, fetched_title = fetch_text_with_meta(d["url"]) + if not title and fetched_title: + title = fetched_title + except Exception as e: # noqa: BLE001 + return jsonify(error=f"Could not read URL: {e}"), 400 + if len(text) < 20: + return jsonify(error="No text to analyze."), 400 + + mode = d.get("mode", "summary") + q = (d.get("q") or "").strip() + lang = (d.get("lang") or "").strip() + preset_id = (d.get("preset_id") or "").strip() + custom_prompt = (d.get("custom_prompt") or "").strip() + + # Pro feature: Custom prompt presets + if preset_id or custom_prompt or mode.startswith("preset:"): + if not is_pro: + return require_pro() + target_id = preset_id or (mode.split(":", 1)[1] if mode.startswith("preset:") else "") + preset_map = {p["id"]: p["prompt"] for p in load_presets()} + task = custom_prompt or preset_map.get(target_id) + if not task: + return jsonify(error=f"Unknown preset: {target_id}"), 400 + mode = f"preset:{target_id}" if target_id else "preset:custom" + else: + task = ( + q + if (mode == "ask" and q) + else MODES.get(mode, MODES["summary"]).format(lang=lang or "the language of the page") + ) + + system = ( + "You are a private on-device assistant. Use only the PAGE text. " + "If the answer is not in the page, say so. " + + (f"Reply in {lang}." if lang else "Reply in the language of the page unless the task says otherwise.") + ) + compute = get_compute_config() + eff_gpu_layers = int(d["gpu_layers"]) if d.get("gpu_layers") is not None and str(d.get("gpu_layers")).isdigit() else compute["gpu_layers"] + eff_cpu_threads = int(d["cpu_threads"]) if d.get("cpu_threads") is not None and str(d.get("cpu_threads")).isdigit() else compute["cpu_threads"] + + payload = { + "model": MODEL, + "stream": True, + "temperature": 0.2, + "max_tokens": MAX_TOKENS, + "n_gpu_layers": eff_gpu_layers, + "options": { + "num_gpu": eff_gpu_layers, + "num_thread": eff_cpu_threads, + "num_ctx": compute["ctx_size"], + }, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": f"PAGE:\n{text[:MAX_CHARS]}\n\nTASK: {task}"}, + ], + } + + def gen(): + acc = [] + try: + with requests.post(LLM_URL, json=payload, stream=True, timeout=(5, 600)) as r: + r.raise_for_status() + for line in r.iter_lines(): + if not line.startswith(b"data:"): + continue + chunk = line[5:].strip() + if chunk == b"[DONE]": + break + try: + delta = json.loads(chunk)["choices"][0]["delta"].get("content") + except (ValueError, KeyError, IndexError): + continue + if delta: + acc.append(delta) + yield delta + full_output = "".join(acc).strip() + if full_output: + try: + LAST_RESULT_FILE.write_text( + json.dumps( + { + "url": d.get("url") or "", + "title": title, + "mode": mode, + "output": full_output, + "date": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"), + } + ), + encoding="utf-8", + ) + except Exception: # noqa: BLE001 + pass + if NOTES_DIR: + save_note(d.get("url") or "", mode, full_output, raw) + if is_pro and d.get("save_library", True): + save_to_library(d.get("url") or "", title, mode, text, full_output) + except Exception as e: # noqa: BLE001 + yield f"\n[Model server not reachable: {e}. Is it running? See README > Troubleshooting.]" + + return Response( + gen(), + mimetype="text/plain; charset=utf-8", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +if __name__ == "__main__": + if HOST not in ("127.0.0.1", "localhost"): + print(f"WARNING: listening on {HOST}. Anyone on that network can reach the API (token required).") + app.run(host=HOST, port=PORT, threaded=True) diff --git a/server/ui.html b/server/ui.html new file mode 100644 index 0000000..c87d7b8 --- /dev/null +++ b/server/ui.html @@ -0,0 +1,361 @@ + + + +Nullcloud — On-Device, No Cloud AI + +
+

Nullcloud Free Core (MIT)

+ Airplane-Mode Ready · CPU + GPU +
+

On-device, no cloud AI. Summarize, extract facts, or question any page, text, or PDF right on your phone or laptop using local CPU + GPU acceleration.

+ + + + + +
+ + +
+ + +
+ + + + +
Output appears here. Small local models (1B/3B) run fast on your device but may make mistakes—verify critical facts.
+
+ + + +
+
+ +
+
+

Saved-Page Library & Offline Search (Pro)

+

Every analyzed page and PDF is indexed locally in SQLite FTS5 (`~/.nullcloud-library.db`) for instant offline search—even in Airplane Mode.

+
+ + +
+
+
+
+ +
+
+

Custom Prompt Presets (Pro)

+

Includes pre-built high-compliance templates for Law Firms, Journalists, and Medical Clinics, plus your own custom instructions.

+ + + + +
+
+
+ +
+
+

Nullcloud Pro — Productivity Hub & Private Cloud

+

Unlock the Saved-Page Library with full-text search, PDF input extraction, Custom Prompt Presets, and Tailscale Private Cloud with a one-time offline license key (verified locally via ECDSA P-256).

+ Get Pro — €19 one-time + Remote setup — €30 + Pro + setup — €45 +

Pay via the link, then email Pozun.alen96@gmail.com with the payment confirmation. The license key is sent manually.

+

Tailscale private cloud (€60-80) and team consulting (from €250): contact Pozun.alen96@gmail.com for a quote.

+ + +
+
+ +
+

CPU & GPU Hardware Acceleration Tweaks

+

Tune both GPU layer offload (Vulkan / OpenCL on Snapdragon Adreno & Arm Mali, Apple Metal, NVIDIA CUDA, AMD ROCm) and CPU big.LITTLE threads + quantized KV cache (q8_0/q4_0):

+
Detecting local CPU + GPU engine...
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Phone as a Private Cloud AI Server (via Tailscale)

+

Turn your phone into a private on-device AI server accessible from your laptop or iPad over your encrypted Tailscale tailnet—without exposing port 8080 to public Wi-Fi:

+
+

1. Install Tailscale on your phone and laptop.

+

2. Start Nullcloud bound to your Tailscale IP: TAILSCALE=1 bash ~/nullcloud/termux/start.sh

+

3. In the laptop Browser Extension or Web UI, set the Server URL to your phone's Tailscale address (http://100.x.y.z:8080).

+
+
+ +
+

High-Compliance Consulting & Community Support

+

Need turn-key on-device AI deployment, custom preset workflows, or Tailscale fleet setup for a law firm, newsroom, or medical clinic? See docs/CONSULTING.md or reach out via GitHub.

+

The free MIT core is supported via GitHub Sponsors.

+
+
+ + diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..a41c4e2 --- /dev/null +++ b/setup.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Unified Nullcloud Setup Script — Automatically detects Termux (Android) vs Desktop (macOS/Linux) +# and deploys all entry points (Browser Extension backend, Web UI, and Android Share Sheet). +set -e +ROOT="$(cd "$(dirname "$0")" && pwd)" + +if [ -n "${TERMUX_VERSION:-}" ] || [ -d "/data/data/com.termux/files/usr" ]; then + echo "Detected Android Termux environment -> Running mobile hyper-optimized setup..." + exec bash "$ROOT/termux/setup.sh" "$@" +else + echo "Detected Desktop environment ($(uname -s)) -> Running desktop setup & server..." + exec bash "$ROOT/desktop/start.sh" "$@" +fi diff --git a/termux/bench.sh b/termux/bench.sh new file mode 100755 index 0000000..fd5fa37 --- /dev/null +++ b/termux/bench.sh @@ -0,0 +1,32 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Battery + CPU/GPU speed benchmark. Usage: bash termux/bench.sh [RUNS] ; env: CAP_MAH=5000 +# For comparable numbers: airplane mode, charger unplugged, same screen brightness, battery >= 80%, model server running. +RUNS="${1:-10}"; CAP="${CAP_MAH:-5000}" +[ -f "$HOME/.nullcloud-profile.env" ] && . "$HOME/.nullcloud-profile.env" +LLM=http://127.0.0.1:8081/v1/chat/completions +SAMPLE="$(python - <<'PY' +print("The quick brown fox jumps over the lazy dog near the river bank. " * 85) +PY +)" +PROMPT="$(printf 'PAGE:\n%s\n\nTASK: Summarize the page in 5 bullet points.' "$SAMPLE")" +bat() { termux-battery-status | jq -r '"\(.percentage) \(.temperature)"'; } +read B0 T0 < <(bat) +TOK=0; SEC=0 +for i in $(seq 1 "$RUNS"); do + S=$(date +%s.%N) + R=$(jq -n --arg p "$PROMPT" '{messages:[{role:"user",content:$p}],max_tokens:200,temperature:0.2}' | \ + curl -s "$LLM" -H 'Content-Type: application/json' -d @-) + E=$(date +%s.%N) + C=$(jq -r '.usage.completion_tokens // 0' <<<"$R") + TOK=$((TOK + C)); SEC=$(awk "BEGIN{print $SEC + $E - $S}") + echo "run $i: $C tokens" +done +read B1 T1 < <(bat) +echo +echo "== Nullcloud CPU + GPU benchmark ==" +echo "model: ${MODEL_NAME:-local} | gpu: ${GPU_NAME:-Mobile GPU} (-ngl ${GPU_LAYERS:-99})" +echo "runs: $RUNS (each ~1,300-token page in, <=200 tokens out)" +awk "BEGIN{printf \"end-to-end speed: %.1f tok/s (includes page reading)\n\", $TOK/$SEC}" +awk "BEGIN{printf \"avg time per article: %.1f s\n\", $SEC/$RUNS}" +echo "battery: ${B0}% -> ${B1}% (temp ${T0}C -> ${T1}C)" +awk "BEGIN{d=$B0-$B1; printf \"drop: %d%% over %d articles = ~%.1f mAh/article at %d mAh (battery %% is coarse; use RUNS>=20)\n\", d, $RUNS, d/100*$CAP/$RUNS, $CAP}" diff --git a/termux/setup.sh b/termux/setup.sh new file mode 100755 index 0000000..63bea57 --- /dev/null +++ b/termux/setup.sh @@ -0,0 +1,110 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Nullcloud Termux Setup — Unified One-Command Installer (Share Sheet + Web UI + Extension Backend) +# Pre-configures Llama 3.2 1B (4GB RAM) or 3B (6GB+ RAM) with CPU + GPU acceleration for 5-15+ tok/s on-device. +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODELS="$HOME/nullcloud-models"; mkdir -p "$MODELS" + +URL_3B="https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf" +URL_1B="https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf" + +# Auto-detect device RAM from /proc/meminfo (in kB) +MEM_KB="$(awk '/MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 6000000)" +MEM_GB="$(awk "BEGIN{printf \"%.1f\", $MEM_KB / 1048576}")" + +# Auto-detect mobile GPU (Snapdragon Adreno, Arm Mali/Immortalis, Samsung Xclipse, or Vulkan ICD) +GPU_NAME="Mobile GPU (Vulkan/OpenCL)" +if [ -r /sys/class/kgsl/kgsl-3d0/gpu_model ]; then + GPU_NAME="Adreno $(tr -d '\0\r\n' < /sys/class/kgsl/kgsl-3d0/gpu_model)" +elif [ -d /sys/class/misc/mali0 ] || [ -e /dev/mali0 ]; then + GPU_NAME="Arm Mali / Immortalis GPU" +elif [ -d /vendor/etc/vulkan/icd.d ] || [ -d /system/vendor/etc/vulkan/icd.d ]; then + ICD="$(ls /vendor/etc/vulkan/icd.d /system/vendor/etc/vulkan/icd.d 2>/dev/null | head -n 1 | tr -d '\r\n')" + [ -n "$ICD" ] && GPU_NAME="Vulkan ($ICD)" +fi +GPU_LAYERS="${GPU_LAYERS:-99}" + +if [ -n "$MODEL_TIER" ]; then + TIER="$MODEL_TIER" +elif [ "$MEM_KB" -lt 5500000 ]; then + TIER="1b" +else + TIER="3b" +fi + +if [ "$TIER" = "1b" ]; then + DEFAULT_URL="$URL_1B" + CTX_SIZE=2048 + MAX_CHARS=4500 + MAX_TOKENS=300 + MODEL_NAME="Llama-3.2-1B-Instruct-Q4_K_M" +else + DEFAULT_URL="$URL_3B" + CTX_SIZE=3072 + MAX_CHARS=6000 + MAX_TOKENS=400 + MODEL_NAME="Llama-3.2-3B-Instruct-Q4_K_M" +fi + +MODEL_URL="${MODEL_URL:-$DEFAULT_URL}" + +echo "== Nullcloud On-Device AI Setup (CPU + GPU Hybrid) ==" +echo "Detected RAM: ${MEM_GB} GB | GPU: ${GPU_NAME}" +echo "Selecting ${MODEL_NAME} profile (target: 5-15+ tok/s offline)" + +pkg update -y && pkg upgrade -y +pkg install -y python curl wget jq termux-api unzip poppler vulkan-loader-android 2>/dev/null || \ + pkg install -y python curl wget jq termux-api unzip poppler + +if [ "${BUILD_VULKAN:-0}" = "1" ]; then + echo "Building llama.cpp from source with Vulkan GPU acceleration (-DGGML_VULKAN=ON)..." + pkg install -y git cmake clang vulkan-headers vulkan-loader-android shaderc + SRC_DIR="$HOME/.cache/llama.cpp-vulkan" + rm -rf "$SRC_DIR" + git clone --depth 1 https://github.com/ggerganov/llama.cpp "$SRC_DIR" + cmake -S "$SRC_DIR" -B "$SRC_DIR/build" -DGGML_VULKAN=ON -DCMAKE_BUILD_TYPE=Release + cmake --build "$SRC_DIR/build" --target llama-server -j"$(nproc 2>/dev/null || echo 4)" + cp "$SRC_DIR/build/bin/llama-server" "$PREFIX/bin/llama-server" +elif ! command -v llama-server >/dev/null; then + pkg install -y llama-cpp 2>/dev/null || pkg install -y llama.cpp 2>/dev/null || { + echo "!! llama.cpp package not found in your Termux repo. Run with BUILD_VULKAN=1 bash termux/setup.sh to build from source."; exit 1; } +fi +pip install -r "$ROOT/server/requirements.txt" + +if [ ! -f "$MODELS/model.gguf" ]; then + echo "Downloading $MODEL_NAME (~1-2 GB one-time download)..." + wget -c -O "$MODELS/model.gguf.part" "$MODEL_URL" && mv "$MODELS/model.gguf.part" "$MODELS/model.gguf" +fi + +if [ ! -f "$HOME/.nullcloud-token" ]; then + head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$HOME/.nullcloud-token" + chmod 600 "$HOME/.nullcloud-token" +fi + +cat > "$HOME/.nullcloud-profile.env" < Termux" -> summary notification) +mkdir -p "$HOME/bin" +cp "$ROOT/termux/termux-url-opener" "$HOME/bin/termux-url-opener" +chmod +x "$HOME/bin/termux-url-opener" "$ROOT/termux/start.sh" "$ROOT/termux/stop.sh" "$ROOT/termux/bench.sh" +echo "$ROOT" > "$HOME/.nullcloud-root" + +echo +echo "== Setup complete (On-Device, No Cloud AI) ==" +echo "Profile: $MODEL_NAME (CTX=$CTX_SIZE, MAX_CHARS=$MAX_CHARS)" +echo "Compute: CPU (big.LITTLE + ARM NEON/dotprod) + GPU Offload ($GPU_NAME, -ngl $GPU_LAYERS)" +echo "Your token: $(cat "$HOME/.nullcloud-token")" +echo "Start: bash $ROOT/termux/start.sh" +echo "Entry points ready:" +echo " 1. Android Share Sheet: Share any link or text -> Termux -> instant summary notification" +echo " 2. Web UI: http://127.0.0.1:8080 (works in any browser, even in Airplane Mode)" +echo " 3. Browser Extension: Load extension/ or connect laptop via Tailscale Private Cloud" diff --git a/termux/start.sh b/termux/start.sh new file mode 100755 index 0000000..bf64def --- /dev/null +++ b/termux/start.sh @@ -0,0 +1,79 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Start Nullcloud on Android (Termux): CPU + GPU hyper-optimized llama-server + local API server. +# Set TAILSCALE=1 to bind to your phone's Tailscale IPv4 for Private Cloud laptop access. +# Set GPU_LAYERS=0 for CPU-only, or GPU_LAYERS=99 (default) for Vulkan/OpenCL mobile GPU offload. +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TOKEN="$(cat "$HOME/.nullcloud-token")" +[ -f "$HOME/.nullcloud-profile.env" ] && . "$HOME/.nullcloud-profile.env" + +CTX_SIZE="${CTX_SIZE:-3072}" +MAX_CHARS="${MAX_CHARS:-6000}" +MAX_TOKENS="${MAX_TOKENS:-400}" +MODEL_NAME="${MODEL_NAME:-Llama-3.2-3B-Instruct-Q4_K_M}" +GPU_LAYERS="${GPU_LAYERS:-99}" +GPU_NAME="${GPU_NAME:-Mobile GPU (Vulkan/OpenCL)}" + +# CPU tweak: detect big/performance cores, cap at 4 on 1B profile or 6 on 3B profile for optimal tok/s & thermals +CORES="$(nproc 2>/dev/null || echo 4)" +if [ "${MODEL_TIER:-3b}" = "1b" ]; then + DEFAULT_THREADS=$(( CORES > 4 ? 4 : CORES )) +else + DEFAULT_THREADS=$(( CORES > 6 ? 6 : CORES )) +fi +THREADS="${THREADS:-$DEFAULT_THREADS}" + +BIND_HOST="${HOST:-127.0.0.1}" +if [ "${TAILSCALE:-0}" = "1" ] && command -v tailscale >/dev/null 2>&1; then + TS_IP="$(tailscale ip -4 2>/dev/null | head -n 1 | tr -d ' \r\n')" + if [ -n "$TS_IP" ]; then + BIND_HOST="$TS_IP" + fi +fi + +termux-wake-lock 2>/dev/null +pkill -f "llama-server.*--port 8081" 2>/dev/null +pkill -f "server/server.py" 2>/dev/null + +# Tier 1: GPU offload (-ngl) + quantized KV cache (q8_0/q4_0) + Flash-Attention + CPU big-core threads +ACCEL_ACTIVE="CPU ($THREADS threads) + GPU ($GPU_NAME, -ngl $GPU_LAYERS, KV q8_0/q4_0, Flash-Attn)" +if [ "$GPU_LAYERS" -gt 0 ] 2>/dev/null; then + nohup llama-server -m "$HOME/nullcloud-models/model.gguf" \ + -c "$CTX_SIZE" -t "$THREADS" -ngl "$GPU_LAYERS" -b 512 -ub 128 \ + --cache-type-k q8_0 --cache-type-v q4_0 --flash-attn \ + --host 127.0.0.1 --port 8081 > "$HOME/nullcloud-llm.log" 2>&1 & + sleep 1 +fi + +# Tier 2 fallback: CPU-optimized quantized KV cache + Flash-Attention (if GPU offload not compiled or GPU_LAYERS=0) +if ! pgrep -f "llama-server.*--port 8081" >/dev/null 2>&1; then + ACCEL_ACTIVE="CPU ($THREADS big-cores, NEON/dotprod, KV q8_0/q4_0, Flash-Attn)" + nohup llama-server -m "$HOME/nullcloud-models/model.gguf" \ + -c "$CTX_SIZE" -t "$THREADS" -b 512 -ub 128 \ + --cache-type-k q8_0 --cache-type-v q4_0 --flash-attn \ + --host 127.0.0.1 --port 8081 > "$HOME/nullcloud-llm.log" 2>&1 & + sleep 1 +fi + +# Tier 3 fallback: Legacy llama-server compatibility +if ! pgrep -f "llama-server.*--port 8081" >/dev/null 2>&1; then + ACCEL_ACTIVE="CPU ($THREADS threads, standard KV)" + nohup llama-server -m "$HOME/nullcloud-models/model.gguf" \ + -c "$CTX_SIZE" -t "$THREADS" \ + --host 127.0.0.1 --port 8081 > "$HOME/nullcloud-llm.log" 2>&1 & +fi + +echo -n "Loading $MODEL_NAME [$ACCEL_ACTIVE, ctx $CTX_SIZE]" +for i in $(seq 1 90); do + curl -s http://127.0.0.1:8081/health 2>/dev/null | grep -q ok && break + echo -n "."; sleep 2 +done +echo + +HOST="$BIND_HOST" MAX_CHARS="$MAX_CHARS" MAX_TOKENS="$MAX_TOKENS" MODEL="$MODEL_NAME" TOKEN="$TOKEN" \ + CPU_THREADS="$THREADS" GPU_LAYERS="$GPU_LAYERS" GPU_NAME="$GPU_NAME" ACCEL_MODE="$ACCEL_ACTIVE" CTX_SIZE="$CTX_SIZE" \ + nohup python "$ROOT/server/server.py" > "$HOME/nullcloud-api.log" 2>&1 & +sleep 2 +echo "Nullcloud is running (on-device, no cloud AI)." +echo "Compute: $ACCEL_ACTIVE" +echo "Web UI: http://${BIND_HOST}:8080 (works in any browser, even in Airplane Mode)" +echo "Token: $TOKEN" diff --git a/termux/stop.sh b/termux/stop.sh new file mode 100755 index 0000000..952c568 --- /dev/null +++ b/termux/stop.sh @@ -0,0 +1,5 @@ +#!/data/data/com.termux/files/usr/bin/bash +pkill -f "llama-server.*--port 8081" +pkill -f "server/server.py" +termux-wake-unlock 2>/dev/null +echo "Stopped." diff --git a/termux/termux-url-opener b/termux/termux-url-opener new file mode 100755 index 0000000..9b56783 --- /dev/null +++ b/termux/termux-url-opener @@ -0,0 +1,78 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Android Share Sheet -> Termux -> Private on-device summary notification (no browser extension needed). +# Works with shared URLs, selected text, and local PDF files (even in Airplane Mode). +INPUT="$1" +[ -z "$INPUT" ] && exit 0 +TOKEN="$(cat "$HOME/.nullcloud-token" 2>/dev/null)" +ROOT="$(cat "$HOME/.nullcloud-root" 2>/dev/null || echo "$HOME/nullcloud")" + +# Thermal safety guard (inspired by Meridian thermal protection) +BATT_TEMP="$(termux-battery-status 2>/dev/null | jq -r '.temperature // 30' 2>/dev/null || echo 30)" +if awk "BEGIN{exit !($BATT_TEMP >= 45)}"; then + termux-notification --id nullcloud --title "Nullcloud paused (${BATT_TEMP}°C)" \ + --content "Phone battery is warm (${BATT_TEMP}°C). Let it cool below 45°C before running local AI." 2>/dev/null + exit 1 +fi + +CHOICE="$(termux-dialog radio -t "Nullcloud (On-Device, No Cloud AI)" \ + -v "Summarize,Summarize + read aloud,Key facts,Explain simply,Claims check,Translate,Legal: Privilege & Risk Scan (Pro),Journalism: Source Check (Pro),Clinical: Summary (Pro)" | jq -r '.text // empty')" +[ -z "$CHOICE" ] && exit 0 + +SPEAK=0; LANG_OUT=""; PRESET_ID="" +case "$CHOICE" in + "Summarize") MODE=summary ;; + "Summarize + read aloud") MODE=summary; SPEAK=1 ;; + "Key facts") MODE=facts ;; + "Explain simply") MODE=eli5 ;; + "Claims check") MODE=claims ;; + "Translate") + MODE=translate + LANG_OUT="$(termux-dialog text -t "Translate to which language?" | jq -r '.text // empty')" + [ -z "$LANG_OUT" ] && LANG_OUT=English ;; + "Legal: Privilege & Risk Scan (Pro)") MODE=summary; PRESET_ID="legal-privilege" ;; + "Journalism: Source Check (Pro)") MODE=summary; PRESET_ID="journalism-source" ;; + "Clinical: Summary (Pro)") MODE=summary; PRESET_ID="clinical-intake" ;; + *) exit 0 ;; +esac + +termux-notification --id nullcloud --title "Nullcloud: $CHOICE" \ + --content "Processing on-device (no cloud AI)..." 2>/dev/null + +if ! curl -s http://127.0.0.1:8080/health >/dev/null; then + bash "$ROOT/termux/start.sh" +fi + +echo "$CHOICE (on-device, no cloud AI): $INPUT"; echo + +# Detect whether INPUT is an http(s) URL, a local PDF file, or raw shared text (Airplane Mode) +if [[ "$INPUT" =~ ^https?:// ]]; then + PAYLOAD="$(jq -n --arg u "$INPUT" --arg m "$MODE" --arg l "$LANG_OUT" --arg p "$PRESET_ID" \ + '{url:$u,mode:$m,lang:$l,preset_id:$p}')" +elif [ -f "$INPUT" ] && [[ "${INPUT,,}" == *.pdf ]]; then + B64="$(base64 -w 0 "$INPUT" 2>/dev/null || base64 "$INPUT" | tr -d '\r\n')" + FNAME="$(basename "$INPUT")" + PAYLOAD="$(jq -n --arg b "$B64" --arg fn "$FNAME" --arg m "$MODE" --arg l "$LANG_OUT" --arg p "$PRESET_ID" \ + '{pdf_base64:$b,filename:$fn,mode:$m,lang:$l,preset_id:$p}')" +elif [ -f "$INPUT" ]; then + TXT="$(head -c 12000 "$INPUT")" + PAYLOAD="$(jq -n --arg t "$TXT" --arg m "$MODE" --arg l "$LANG_OUT" --arg p "$PRESET_ID" \ + '{text:$t,mode:$m,lang:$l,preset_id:$p}')" +else + PAYLOAD="$(jq -n --arg t "$INPUT" --arg m "$MODE" --arg l "$LANG_OUT" --arg p "$PRESET_ID" \ + '{text:$t,mode:$m,lang:$l,preset_id:$p}')" +fi + +printf '%s' "$PAYLOAD" | \ + curl -sN -X POST http://127.0.0.1:8080/analyze -H "X-Token: $TOKEN" \ + -H "Content-Type: application/json" -d @- | tee "$HOME/.nullcloud-last.txt" +echo + +SUMMARY_HEAD="$(head -c 500 "$HOME/.nullcloud-last.txt")" +termux-notification --id nullcloud --title "Nullcloud: $CHOICE" \ + --content "$SUMMARY_HEAD" \ + --button1 "Copy" --button1-action "termux-clipboard-set < $HOME/.nullcloud-last.txt" \ + --button2 "Open UI" --button2-action "termux-open-url http://127.0.0.1:8080" 2>/dev/null + +# Read aloud uses Android's TTS engine. Fully offline when an offline voice is installed +# (Android Settings > Text-to-speech > install voice data). +[ "$SPEAK" = 1 ] && termux-tts-speak < "$HOME/.nullcloud-last.txt" diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..7f0ab66 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,222 @@ +import base64 +import json +import os +import subprocess +import sys +import zlib +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "server")) + +os.environ["NULLCLOUD_TESTING"] = "1" +os.environ["TOKEN"] = "test-secret-token-12345" + +import server # noqa: E402 + + +@pytest.fixture() +def app_client(tmp_path, monkeypatch): + monkeypatch.setattr(server, "TOKEN", "test-secret-token-12345") + monkeypatch.setattr(server, "LICENSE_FILE", tmp_path / ".nullcloud-pro.key") + monkeypatch.setattr(server, "LIBRARY_DB", tmp_path / ".nullcloud-library.db") + monkeypatch.setattr(server, "PRESETS_FILE", tmp_path / ".nullcloud-presets.json") + monkeypatch.setattr(server, "LAST_RESULT_FILE", tmp_path / ".nullcloud-last.json") + monkeypatch.setattr(server, "COMPUTE_FILE", tmp_path / ".nullcloud-compute.json") + monkeypatch.setattr(server, "NOTES_DIR", str(tmp_path / "vault")) + monkeypatch.setattr(server, "ARCHIVE", True) + return server.app.test_client() + + +@pytest.fixture() +def pro_key(tmp_path): + """Generate a fresh ECDSA P-256 keypair and signed Pro token via tools/license/.""" + key_dir = tmp_path / "keys" + subprocess.run(["node", str(ROOT / "tools/license/keygen.mjs"), str(key_dir)], check=True, capture_output=True) + pub_b64 = (key_dir / "public.b64").read_text(encoding="utf-8").strip() + res = subprocess.run( + ["node", str(ROOT / "tools/license/sign.mjs"), "--ref", "ORDER-999", "--key", str(key_dir / "private.pem"), "--sku", "pro"], + check=True, + capture_output=True, + text=True, + ) + signed_key = res.stdout.strip() + return pub_b64, signed_key + + +def _mock_llm_stream(chunks=("Bullet 1\n", "Bullet 2\n", "Why it matters.")): + lines = [f'data: {{"choices":[{{"delta":{{"content":{json.dumps(c)}}}}}]}}'.encode("utf-8") for c in chunks] + lines.append(b"data: [DONE]") + mock_resp = MagicMock() + mock_resp.__enter__.return_value = mock_resp + mock_resp.raise_for_status.return_value = None + mock_resp.iter_lines.return_value = lines + return mock_resp + + +def test_health_and_branding(app_client): + r = app_client.get("/health") + assert r.status_code == 200 + d = r.get_json() + assert d["ok"] is True + assert d["mode"] == "on-device, no cloud AI" + assert d["offline_ready"] is True + assert d["pro"] is False + assert "compute" in d + assert d["compute"]["gpu_layers"] == 99 + assert d["compute"]["cpu_threads"] >= 1 + + +def test_cpu_and_gpu_hardware_tweaks(app_client): + headers = {"X-Token": "test-secret-token-12345"} + r0 = app_client.get("/hardware", headers=headers) + assert r0.status_code == 200 + assert r0.get_json()["compute"]["gpu_layers"] == 99 + + r1 = app_client.post( + "/hardware", + headers=headers, + json={"gpu_layers": 16, "cpu_threads": 4, "flash_attn": True, "kv_cache": "q8_0/q4_0"}, + ) + assert r1.status_code == 200 + c = r1.get_json()["compute"] + assert c["gpu_layers"] == 16 + assert c["cpu_threads"] == 4 + assert "GPU" in c["accel_summary"] and "CPU" in c["accel_summary"] + + +def test_auth_required(app_client): + r = app_client.post("/analyze", json={"text": "Some long text that should require authentication."}) + assert r.status_code == 401 + r2 = app_client.post( + "/analyze", + headers={"X-Token": "wrong-token"}, + json={"text": "Some long text that should require authentication."}, + ) + assert r2.status_code == 401 + + +def test_ssrf_blocks_private_and_loopback(): + for bad_url in [ + "http://127.0.0.1:8080/health", + "http://localhost/admin", + "http://0.0.0.0/", + "http://printer.local/status", + "ftp://example.com/file", + ]: + with pytest.raises(ValueError): + server.validate_public_url(bad_url) + + +def test_free_core_airplane_mode_text_analysis(app_client): + with patch("server.requests.post", return_value=_mock_llm_stream()): + r = app_client.post( + "/analyze", + headers={"X-Token": "test-secret-token-12345"}, + json={ + "text": "This is an open article analyzed completely offline in Airplane Mode on the phone.", + "mode": "summary", + "url": "https://example.com/article", + }, + ) + assert r.status_code == 200 + assert "Bullet 1" in r.get_data(as_text=True) + + last = app_client.get("/last", headers={"X-Token": "test-secret-token-12345"}) + assert last.status_code == 200 + assert "Bullet 1" in last.get_json()["output"] + + +def test_pro_features_gated_on_free_tier(app_client): + headers = {"X-Token": "test-secret-token-12345"} + # 1. Library is gated + r_lib = app_client.get("/library", headers=headers) + assert r_lib.status_code == 402 + locked = r_lib.get_json() + assert locked["pro_required"] is True + assert locked["offers"][0]["price"] == "€19 one-time" + assert locked["offers"][0]["url"] == "https://checkout.revolut.com/pay/d25ddfdd-7351-465b-afb6-c6238f65c575" + assert locked["offers"][1]["price"] == "€30" + assert locked["offers"][2]["price"] == "€45" + assert "Pozun.alen96@gmail.com" in locked["payment_flow"] + assert "€60-80" in locked["quote"] + assert all(item["url"].startswith("https://checkout.revolut.com/pay/") for item in locked["offers"]) + + # 2. Custom presets are gated + r_pre = app_client.post("/presets", headers=headers, json={"name": "Test", "prompt": "Do X"}) + assert r_pre.status_code == 402 + + # 3. PDF input is gated + fake_pdf = base64.b64encode(b"%PDF-1.4\n(Hello PDF) Tj\n").decode("ascii") + r_pdf = app_client.post("/analyze", headers=headers, json={"pdf_base64": fake_pdf, "mode": "summary"}) + assert r_pdf.status_code == 402 + + # 4. Preset mode in /analyze is gated + r_pm = app_client.post( + "/analyze", + headers=headers, + json={"text": "Enough characters to analyze with a preset mode.", "mode": "preset:legal-privilege"}, + ) + assert r_pm.status_code == 402 + + +def test_offline_ecdsa_pro_activation_and_features(app_client, pro_key, monkeypatch): + pub_b64, signed_key = pro_key + monkeypatch.setattr(server, "LICENSE_PUBKEY", pub_b64) + headers = {"X-Token": "test-secret-token-12345"} + + # Reject tampered or fake keys + bad = app_client.post("/pro/activate", headers=headers, json={"key": "paid"}) + assert bad.status_code == 400 + tampered = signed_key[:-4] + "AAAA" + bad2 = app_client.post("/pro/activate", headers=headers, json={"key": tampered}) + assert bad2.status_code == 400 + + # Activate valid signed Pro key offline + act = app_client.post("/pro/activate", headers=headers, json={"key": signed_key}) + assert act.status_code == 200 + act_data = act.get_json() + assert act_data["pro"] is True + assert act_data["claims"]["sku"] == "pro" + assert "tailscale" in act_data + + # Verify Pro presets (Legal, Journalism, Clinical, Executive) + custom preset creation + pre_res = app_client.get("/presets", headers=headers).get_json() + assert pre_res["pro"] is True + preset_ids = {p["id"] for p in pre_res["presets"]} + assert {"legal-privilege", "journalism-source", "clinical-intake", "exec-brief"}.issubset(preset_ids) + + add_pre = app_client.post( + "/presets", + headers=headers, + json={"name": "M&A Term Sheet Check", "category": "Legal", "prompt": "Extract valuation and liquidation preferences."}, + ) + assert add_pre.status_code == 200 + + # Verify Pro PDF extraction (FlateDecode compressed stream) + stream_body = zlib.compress(b"BT /F1 12 Tf 72 712 Td (Confidential Legal Contract Clause 42: Governing Law and Arbitration) Tj ET") + pdf_bytes = b"%PDF-1.4\n1 0 obj\n<< /Length " + str(len(stream_body)).encode() + b" /Filter /FlateDecode >>\nstream\n" + stream_body + b"\nendstream\nendobj\n%%EOF" + pdf_b64 = base64.b64encode(pdf_bytes).decode("ascii") + + with patch("server.requests.post", return_value=_mock_llm_stream(("Clause 42 summary.",))): + r_pdf = app_client.post( + "/analyze", + headers=headers, + json={"pdf_base64": pdf_b64, "filename": "contract.pdf", "mode": "preset:legal-privilege"}, + ) + assert r_pdf.status_code == 200 + assert "Clause 42 summary." in r_pdf.get_data(as_text=True) + + # Verify Saved-Page Library & SQLite FTS5 Search + lib_all = app_client.get("/library", headers=headers).get_json() + assert lib_all["count"] == 1 + assert lib_all["items"][0]["title"] == "contract.pdf" + + lib_hit = app_client.get("/library?q=Arbitration", headers=headers).get_json() + assert lib_hit["count"] == 1 + + lib_miss = app_client.get("/library?q=nonexistentwordxyz", headers=headers).get_json() + assert lib_miss["count"] == 0 diff --git a/tools/license/keygen.mjs b/tools/license/keygen.mjs new file mode 100755 index 0000000..aca3819 --- /dev/null +++ b/tools/license/keygen.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +// Generates the ECDSA P-256 signing keypair for Nullcloud Pro / Private Cloud license keys. +// +// node tools/license/keygen.mjs [outDir] +// +// Writes /private.pem (keep secret, never commit) and /public.b64, +// and prints the base64 SPKI public key used by server/server.py (LICENSE_PUBKEY). +import { generateKeyPairSync } from "node:crypto"; +import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const outDir = process.argv[2] || join(import.meta.dirname, "keys"); +mkdirSync(outDir, { recursive: true }); +const privPath = join(outDir, "private.pem"); +if (existsSync(privPath)) { + console.error(`Refusing to overwrite ${privPath}. Delete it first if you really want a new keypair.`); + process.exit(1); +} + +const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); +writeFileSync(privPath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 }); +const spki = publicKey.export({ type: "spki", format: "der" }).toString("base64"); +writeFileSync(join(outDir, "public.b64"), spki + "\n"); + +console.log(`Private key: ${privPath} (back it up offline — never commit private.pem)`); +console.log(`Public key: ${join(outDir, "public.b64")}`); +console.log(""); +console.log("Paste this into server/server.py (or set NULLCLOUD_PUBKEY env var):"); +console.log(""); +console.log(`DEFAULT_LICENSE_PUBKEY = "${spki}"`); diff --git a/tools/license/keys/public.b64 b/tools/license/keys/public.b64 new file mode 100644 index 0000000..6c2f389 --- /dev/null +++ b/tools/license/keys/public.b64 @@ -0,0 +1 @@ +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuc4zK4+ZGpUSY/Qxb6RoRgim4UePW7GlkvdEmZ5rIapjL/7xQh9Ja38fEt2yx1M5t2l+R+6WmiUf0bZuhfkF2A== diff --git a/tools/license/sign.mjs b/tools/license/sign.mjs new file mode 100755 index 0000000..dfa5f20 --- /dev/null +++ b/tools/license/sign.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// Issues a signed offline Nullcloud license key after a Revolut payment is confirmed by email. +// +// node tools/license/sign.mjs --ref ORDER-1234 [--key tools/license/keys/private.pem] [--sku pro|team] [--copies 2] +// +// Output format (what the customer pastes into the Web UI, Extension, or ~/.nullcloud-pro.key): +// NULLCLOUD-. +// payload = {"sku":"pro"|"team","iat":,"ref":"ORDER-1234"} +// signature = SHA256withECDSA over the raw payload bytes, DER encoded. +// Verified 100% offline in server/server.py with zero phone-home or cloud dependency. +import { createSign, createPrivateKey } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const args = Object.fromEntries( + process.argv.slice(2).reduce((acc, a, i, arr) => { + if (a.startsWith("--")) { + acc.push([a.slice(2), arr[i + 1] && !arr[i + 1].startsWith("--") ? arr[i + 1] : "true"]); + } + return acc; + }, []) +); + +const ref = args.ref; +if (!ref) { + console.error("Usage: node tools/license/sign.mjs --ref [--key tools/license/keys/private.pem] [--sku pro|team] [--copies 1]"); + process.exit(1); +} +const keyPath = args.key || join(import.meta.dirname, "keys", "private.pem"); +const sku = args.sku || "pro"; +if (sku !== "pro" && sku !== "team" && sku !== "premium") { + console.error("sku must be pro, team, or premium"); + process.exit(1); +} + +const copies = args.copies == null ? 1 : Number(args.copies); +if (!Number.isInteger(copies) || copies < 1 || copies > 50) { + console.error("copies must be a whole number from 1 to 50"); + process.exit(1); +} + +const key = createPrivateKey(readFileSync(keyPath)); +const b64url = (b) => b.toString("base64url"); +const iat = Math.floor(Date.now() / 1000); +for (let n = 1; n <= copies; n++) { + const copyRef = copies === 1 ? ref : `${ref}-${n}`; + const payload = Buffer.from(JSON.stringify({ sku, iat, ref: copyRef }), "utf8"); + const signer = createSign("SHA256"); + signer.update(payload); + const sig = signer.sign({ key, dsaEncoding: "der" }); + console.log(`NULLCLOUD-${b64url(payload)}.${b64url(sig)}`); +} diff --git a/tools/license/verify.mjs b/tools/license/verify.mjs new file mode 100755 index 0000000..ce649ab --- /dev/null +++ b/tools/license/verify.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Verifies a Nullcloud Pro / Team license key offline using the same P-256 SPKI check as server/server.py. +// +// node tools/license/verify.mjs NULLCLOUD-xxxx.yyyy [--pub tools/license/keys/public.b64] +import { createVerify, createPublicKey } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const argv = process.argv.slice(2); +const key = argv.find((a) => !a.startsWith("--")); +const pubIx = argv.indexOf("--pub"); +const pubPath = pubIx >= 0 ? argv[pubIx + 1] : join(import.meta.dirname, "keys", "public.b64"); +if (!key) { + console.error("Usage: node tools/license/verify.mjs NULLCLOUD-... [--pub tools/license/keys/public.b64]"); + process.exit(1); +} + +let k = key.trim(); +if (k.startsWith("NULLCLOUD-")) k = k.slice("NULLCLOUD-".length); +else if (k.startsWith("MERIDIAN-")) k = k.slice(9); +const dot = k.indexOf("."); +if (dot <= 0) { + console.error("Not a valid Nullcloud license key."); + process.exit(2); +} +const payload = Buffer.from(k.slice(0, dot), "base64url"); +const sig = Buffer.from(k.slice(dot + 1), "base64url"); +const pub = createPublicKey({ + key: Buffer.from(readFileSync(pubPath, "utf8").trim(), "base64"), + format: "der", + type: "spki", +}); + +const verifier = createVerify("SHA256"); +verifier.update(payload); +const ok = verifier.verify({ key: pub, dsaEncoding: "der" }, sig); +const claims = JSON.parse(payload.toString("utf8")); +console.log(JSON.stringify({ ok, claims }, null, 2)); +process.exit(ok && ["pro", "team", "premium"].includes(claims.sku) ? 0 : 3); diff --git a/tools/qa/verify-all.sh b/tools/qa/verify-all.sh new file mode 100755 index 0000000..fa3be34 --- /dev/null +++ b/tools/qa/verify-all.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Quality & Architecture Gate for Nullcloud +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +echo "== 1. Checking syntax of all shell scripts ==" +for sh_file in setup.sh desktop/start.sh termux/setup.sh termux/start.sh termux/stop.sh termux/bench.sh termux/termux-url-opener; do + bash -n "$sh_file" + echo " [OK] $sh_file" +done + +echo "== 2. Checking strict branding ('on-device, no cloud AI', never claiming to be 'air-gapped') ==" +if grep -rn "air-gapped" server/ extension/ termux/ desktop/ docs/ >/dev/null 2>&1; then + echo "FAIL: Found forbidden 'air-gapped' claim in code/docs" + exit 1 +fi +grep -q "on-device, no cloud AI" README.md +grep -q "on-device, no cloud AI" server/server.py +echo " [OK] Branding verified" + +echo "== 3. Checking Extension MV3 manifest & files ==" +python3 -c ' +import json, pathlib +m = json.loads(pathlib.Path("extension/manifest.json").read_text()) +assert m["manifest_version"] == 3 +assert "CHANGE-ME" not in json.dumps(m), "Placeholder gecko ID still present" +for icon in ("16.png", "48.png", "128.png"): + assert pathlib.Path("extension/icons/" + icon).is_file() +' +echo " [OK] Extension manifest valid" + +echo "== 4. Testing offline ECDSA P-256 license tools (keygen, sign, verify) ==" +TMP_KEYS="$(mktemp -d)" +trap 'rm -rf "$TMP_KEYS"' EXIT +node tools/license/keygen.mjs "$TMP_KEYS" >/dev/null +KEY_PRO="$(node tools/license/sign.mjs --ref QA-100 --key "$TMP_KEYS/private.pem" --sku pro)" +node tools/license/verify.mjs "$KEY_PRO" --pub "$TMP_KEYS/public.b64" >/dev/null +KEYS_2="$(node tools/license/sign.mjs --ref QA-200 --key "$TMP_KEYS/private.pem" --sku pro --copies 2)" +[ "$(printf '%s\n' "$KEYS_2" | wc -l)" -eq 2 ] +echo " [OK] Offline P-256 license tools verified" + +echo "== 5. Running pytest suite ==" +python3 -m pytest -q +echo "== All Nullcloud QA checks passed! =="