Initial commit: NexusOS - local AI assistant platform

This commit is contained in:
Jon Wingender
2026-07-21 22:48:38 -05:00
commit 714b9fc890
850 changed files with 28278 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# The Linux-only half of a backup: snapshot the LIVE desktop wiring into the repo
# (reference copy for recovery/diffing; assets/themes/install-theme.sh remains the
# applier on restore) and copy the Claude memory notes in so git commits their
# content. None of it exists on Windows, which is why it isn't in bin/sync.py.
#
# Not meant to be run by hand — it's the `--full` stage of:
# python bin/sync.py backup --full (or `ncp backup -f`)
# Set by bin/sync.py to the repo it was invoked from, so a clone in a scratch dir
# operates on itself instead of reaching into the real ~/nexus-core.
NEXUS_ROOT="${NEXUS_ROOT:-$HOME/nexus-core}"
cd "$NEXUS_ROOT" || { echo "No $NEXUS_ROOT"; exit 1; }
snap="$NEXUS_ROOT/assets/themes/restore-snapshot"
mkdir -p "$snap"
{
# No timestamp here — git records commit time, and a date line makes every
# `backup full` dirty this file and commit even when nothing changed.
echo "# NexusOS live desktop wiring — reference snapshot"
echo "# Reference only. Restore is done by assets/themes/install-theme.sh"
for p in /Net/ThemeName /Net/IconThemeName /Gtk/CursorThemeName \
/Gtk/CursorThemeSize /Gtk/FontName; do
echo "xsettings $p = $(xfconf-query -c xsettings -p "$p" 2>/dev/null)"
done
echo "xfwm4 /general/theme = $(xfconf-query -c xfwm4 -p /general/theme 2>/dev/null)"
} > "$snap/xfconf.txt" 2>/dev/null || true
cp -f "$HOME/.config/gtk-3.0/settings.ini" "$snap/gtk-3.0-settings.ini" 2>/dev/null || true
cp -f "$HOME/.config/gtk-4.0/settings.ini" "$snap/gtk-4.0-settings.ini" 2>/dev/null || true
cp -f "$HOME/.config/xfce4/panel/genmon-13.rc" \
"$NEXUS_ROOT/management/panel/genmon-13.rc" 2>/dev/null || true
# The xfconf channel XMLs ARE the desktop: panel layout/size/colour, the
# wallpaper, compositing + keybindings, terminal profile. Copying the files
# is the whole restore — no per-property xfconf-query scripting needed.
# displays.xml is deliberately excluded (monitor-specific; would break another box).
xml_src="$HOME/.config/xfce4/xfconf/xfce-perchannel-xml"
mkdir -p "$snap/xfconf-xml"
for c in xfce4-panel xfce4-desktop xfwm4 xsettings xfce4-keyboard-shortcuts xfce4-terminal; do
cp -f "$xml_src/$c.xml" "$snap/xfconf-xml/$c.xml" 2>/dev/null || true
done
# Multi-monitor primary-follow watcher lives in ~/.local/bin, not the repo.
cp -f "$HOME/.local/bin/plank-primary-watch.sh" \
"$NEXUS_ROOT/bin/panel/plank-primary-watch.sh" 2>/dev/null || true
# Trailing /. copies the CONTENTS — plain `cp -r src dst` nests into dst/src
# once dst exists, burying the dock one level deeper on every backup.
rm -rf "$snap/plank"
if [ -d "$HOME/.config/plank" ]; then
mkdir -p "$snap/plank"
cp -rf "$HOME/.config/plank/." "$snap/plank/" 2>/dev/null || true
fi
# Copy the Claude memory notes (machine knowledge under ~/.claude) into the
# repo so they're versioned too. Some aren't Nexus-specific (Fusion 360, etc.).
notes_src="$HOME/.claude/projects/-home-jon-nexus-core/memory"
notes_dst="$NEXUS_ROOT/assets/notes"
mkdir -p "$notes_dst"
cp -f "$notes_src"/*.md "$notes_dst/" 2>/dev/null || true
echo "Snapshotted desktop wiring + Claude notes."
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# One command that answers "is this shippable" - Python tests + frontend lint.
#
# ponytail: this IS the CI. The remote is self-hosted Gitea with no act_runner,
# so a .github/workflows file would never execute. Run this before tagging a
# release; wire it to a runner the day one exists.
set -uo pipefail
cd "$(dirname "$0")/.."
fail=0
if [ ! -x Promethean/bin/python ]; then
echo "!! no Promethean venv - run bin/install.sh first" >&2
exit 1
fi
echo "== pytest =="
# Explicit dirs: a bare `pytest` would walk Promethean/ and node_modules too.
Promethean/bin/python -m pytest -q tests management || fail=1
echo "== eslint =="
if [ -d interface/web/node_modules ]; then
(cd interface/web && npm run lint) || fail=1
else
echo "-- skipped: interface/web/node_modules missing (npm install)"
fi
echo "== powershell parse =="
# The Windows installer has died at parse twice. Cheap to catch here if pwsh
# happens to be installed on the Linux box; the ASCII guard in tests/ is the
# portable half of the same check.
if command -v pwsh >/dev/null; then
for f in install-windows.ps1 launch_nexus.ps1; do
pwsh -NoProfile -Command "\$e=\$null
[System.Management.Automation.Language.Parser]::ParseFile('$f',[ref]\$null,[ref]\$e) | Out-Null
if (\$e) { \$e | ForEach-Object { Write-Host '$f:' \$_.Message }; exit 1 }" || fail=1
done
else
echo "-- skipped: pwsh not installed"
fi
[ "$fail" -eq 0 ] && echo "OK" || echo "FAILED"
exit "$fail"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Ensure a runnable Ollama binary exists at <nexus_root>/ollama/bin/ollama.
#
# ollama/ is gitignored, so a `git clone` checkout has no binary — download the
# official Linux x86-64 build (pinned to the version the native box ships) so
# clone installs are self-sufficient. A folder-copy install already has the
# binary and this is a no-op. Single source of the pinned version for both
# bin/restore-linux.sh (the desktop stage of a restore).
#
# fetch-ollama.sh <nexus_root>
# NEXUS_OLLAMA_VERSION=vX.Y.Z fetch-ollama.sh <nexus_root> # override pin
set -euo pipefail
root="${1:?usage: fetch-ollama.sh <nexus_root>}"
bin="$root/ollama/bin/ollama"
version="${NEXUS_OLLAMA_VERSION:-v0.21.1}"
# Already present and runnable → nothing to do.
if [ -x "$bin" ] && "$bin" --version &>/dev/null; then
exit 0
fi
case "$(uname -m)" in
x86_64|amd64) ;;
*)
echo "ERROR: Ollama download supports x86-64 only (detected $(uname -m))." >&2
exit 1
;;
esac
# ponytail: stock amd64 asset ships CPU + CUDA runners, not ROCm. An AMD/ROCm
# box wanting GPU offload needs the `-rocm` asset; the CPU runner works meanwhile
# (the native box runs num_gpu=0 anyway). Swap the asset name if that changes.
echo "Ollama binary missing — downloading $version..."
url="https://github.com/ollama/ollama/releases/download/${version}/ollama-linux-amd64.tar.zst"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fSL --retry 3 -o "$tmp" "$url" \
|| { echo "ERROR: could not download Ollama from $url" >&2; exit 1; }
mkdir -p "$root/ollama"
tar --zstd -xf "$tmp" -C "$root/ollama" \
|| { echo "ERROR: could not extract Ollama tarball (need zstd + tar --zstd)." >&2; exit 1; }
chmod +x "$bin" 2>/dev/null || true
"$bin" --version &>/dev/null \
|| { echo "ERROR: downloaded Ollama could not run (expected x86-64 at $bin)." >&2; exit 1; }
echo "Ollama $version ready."
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
import re, subprocess, sys
from pathlib import Path
NEXUS_ROOT = Path.home() / "nexus-core"
NVIDIA_REQS = NEXUS_ROOT / "requirements-nvidia.txt"
CUDA_TO_WHEEL = [
((12, 8), "cu128"),
((12, 6), "cu126"),
((12, 4), "cu124"),
((12, 1), "cu121"),
((11, 8), "cu118"),
]
def detect_cuda():
try:
out = subprocess.run(["nvidia-smi"], capture_output=True, text=True, timeout=10).stdout
m = re.search(r"CUDA Version:\s*(\d+)\.(\d+)", out)
if m:
return int(m.group(1)), int(m.group(2))
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return None, None
def wheel_suffix(major, minor):
for (req_major, req_minor), suffix in CUDA_TO_WHEEL:
if (major, minor) >= (req_major, req_minor):
return suffix
return "cu118"
def main():
print("Detecting NVIDIA GPU...")
major, minor = detect_cuda()
if major is None:
print("Error: nvidia-smi not found or CUDA version unreadable.")
print("Ensure NVIDIA drivers are installed and nvidia-smi is on your PATH.")
sys.exit(1)
print(f"CUDA {major}.{minor} detected.")
suffix = wheel_suffix(major, minor)
print(f"PyTorch wheel: {suffix}")
NVIDIA_REQS.write_text(f"""\
# --- Force NVIDIA/CUDA Priority ---
--index-url https://download.pytorch.org/whl/{suffix}
--extra-index-url https://pypi.org/simple
-r requirements-base.txt
# GPU Compute Stack
torch
torchaudio
torchvision
""")
print(f"\nWritten: {NVIDIA_REQS}")
print("Run 'ncp backup' to push it to the router.")
if __name__ == "__main__":
main()
+168
View File
@@ -0,0 +1,168 @@
#!/bin/bash
# NexusOS installer — Linux side.
# Syncs the repo, builds the Python venv, installs frontend deps, registers ncp,
# and installs the Promethean Terminal + panel.
#
# ./install.sh Linux install (default)
# ./install.sh -w | --windows Hand off to the Windows installer (install-windows.ps1)
NEXUS_ROOT="$HOME/nexus-core"
ROUTER_BACKUP="router:/tmp/mnt/Wingdrive2/nexus-core/"
# ─── Helpers ──────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: install.sh [-w|--windows] [-h|--help]
(no flags) Run the Linux install.
-w, --windows Print how to run the native-Windows installer
(install-windows.ps1 — winget-based, no WSL).
-h, --help Show this help.
EOF
}
detect_requirements() {
if command -v nvidia-smi &>/dev/null && nvidia-smi &>/dev/null 2>&1; then
echo "requirements-nvidia.txt"
elif lspci 2>/dev/null | grep -qi nvidia; then
echo "requirements-nvidia.txt"
elif grep -qi microsoft /proc/version 2>/dev/null; then
echo "requirements-wsl.txt"
elif lspci 2>/dev/null | grep -qi amd; then
echo "requirements-amd.txt"
else
echo "requirements-wsl.txt"
fi
}
run_windows() {
local ps1="$NEXUS_ROOT/install-windows.ps1"
if [ ! -f "$ps1" ]; then
echo "Error: $ps1 not found." >&2
exit 1
fi
# install-windows.ps1 is a native-Windows installer: it self-elevates to
# Administrator and uses winget (Python/Node/Ollama) directly. No WSL.
# It must run from Windows PowerShell, so just point the way.
cat <<EOF
The Windows installer must be run from Windows, not from this shell.
1. Open a PowerShell window in the nexus-core folder.
2. Run: powershell -ExecutionPolicy Bypass -File .\install-windows.ps1
It self-elevates, installs Python/Node/Ollama via winget, builds the venv and
web UI, and drops a desktop shortcut. NexusOS then runs single-process on :8000.
EOF
}
# ─── Arg parsing ──────────────────────────────────────────────────────────────
case "${1:-}" in
-w|--windows)
run_windows
exit $?
;;
-h|--help)
usage
exit 0
;;
"")
;;
*)
echo "Unknown option: $1" >&2
usage
exit 1
;;
esac
# ─── Step 1: Sync from router ─────────────────────────────────────────────────
echo "Pulling Nexus from router..."
mkdir -p "$NEXUS_ROOT"
rsync -avz --delete \
--exclude='.git/' \
--exclude='Promethean/' \
--exclude='models/blobs/' \
--exclude='ollama/' \
--exclude='interface/web/node_modules/' \
--exclude='interface/web/dist/' \
--exclude='runtime/' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
-e ssh \
"$ROUTER_BACKUP" "$NEXUS_ROOT/"
# ─── Step 2: Python venv ──────────────────────────────────────────────────────
echo ""
echo "Creating Python environment..."
python3 -m venv "$NEXUS_ROOT/Promethean"
# ─── Step 3: pip install ──────────────────────────────────────────────────────
echo ""
echo "Installing Python dependencies..."
req=$(detect_requirements)
echo "Detected: $req"
if [ -f "$NEXUS_ROOT/$req" ]; then
"$NEXUS_ROOT/Promethean/bin/pip" install --upgrade pip -q
"$NEXUS_ROOT/Promethean/bin/pip" install -r "$NEXUS_ROOT/$req"
else
echo "Warning: $req not found — skipping pip install."
fi
# ─── Step 4: npm install ──────────────────────────────────────────────────────
echo ""
echo "Installing frontend dependencies..."
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
if command -v npm &>/dev/null; then
cd "$NEXUS_ROOT/interface/web" && npm install
else
echo "npm not found — install nvm/node then run 'cd $NEXUS_ROOT/interface/web && npm install'."
fi
# ─── Step 5: Register ncp in ~/.bashrc ───────────────────────────────────────
echo ""
echo "Registering ncp..."
if ! grep -q "nexus-core/management/nexus-cli.sh" "$HOME/.bashrc"; then
cat >> "$HOME/.bashrc" << 'EOF'
# Nexus
ncp() {
~/nexus-core/management/nexus-cli.sh "$@"
}
EOF
echo "ncp registered in ~/.bashrc."
else
echo "ncp already in ~/.bashrc — skipping."
fi
if ! grep -qF "alias promethean='source ~/nexus-core/.promethean_bashrc'" "$HOME/.bashrc"; then
printf '\n# Promethean\nalias promethean='"'"'source ~/nexus-core/.promethean_bashrc'"'"'\n' >> "$HOME/.bashrc"
echo "promethean registered in ~/.bashrc."
else
echo "promethean already in ~/.bashrc — skipping."
fi
# ─── Step 6: Promethean Terminal + panel ─────────────────────────────────────
echo ""
echo "Installing Promethean Terminal..."
bash "$NEXUS_ROOT/bin/promethean/install.sh" || \
echo "Warning: Promethean Terminal install failed — run bin/promethean/install.sh manually."
echo ""
echo "Installing NexusOS panel applet..."
bash "$NEXUS_ROOT/bin/panel/install.sh" || \
echo "Warning: panel install failed — run bin/panel/install.sh manually."
# ─── Done ─────────────────────────────────────────────────────────────────────
echo ""
echo "Installation complete. Nexus is ready."
echo "Run 'ncp start' to launch Nexus."
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Open the NexusOS UI in a native window.
Uses pywebview, which renders via the WebView2 runtime on Windows (already
present on Win10/11) -- a real app window with no browser chrome and none of the
Edge --app profile cold-start. Blocks until the window is closed; the launcher
waits on this process and stops the services when it exits.
Falls back to the default browser if pywebview/WebView2 is unavailable, staying
alive so the launcher doesn't tear the services down underneath it.
"""
import sys
import time
import urllib.request
URL = "http://localhost:8000"
def _wait_for_backend(timeout: float = 40.0) -> bool:
"""Poll /status until the backend answers, so the window never loads before
the server is up (which shows a localhost error the webview won't retry)."""
status_url = URL.rstrip("/") + "/status"
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(status_url, timeout=2) as r:
if r.status == 200:
return True
except Exception:
time.sleep(1)
return False
def main() -> int:
try:
import webview
except Exception as e: # pywebview not installed
return _browser_fallback(f"pywebview unavailable ({e})")
if not _wait_for_backend():
print("[nexus] backend not reachable on :8000 after 40s.", file=sys.stderr)
try:
webview.create_window(
"NexusOS",
URL,
width=1200,
height=800,
min_size=(900, 600),
)
webview.start() # blocks until the window is closed
return 0
except Exception as e: # no WebView2 runtime / backend failure
return _browser_fallback(f"native window failed ({e})")
def _browser_fallback(reason: str) -> int:
import webbrowser
print(f"[nexus] {reason}; opening default browser instead.", file=sys.stderr)
webbrowser.open(URL)
# Stay alive so the launcher keeps the services up. The user stops NexusOS
# by closing the launcher (or the browser tab, then the services idle out).
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
return 0
if __name__ == "__main__":
sys.exit(main())
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Genmon bluetooth applet — PNG icon + status, click opens blueman-manager.
# Replaces blueman's StatusNotifier tray icon (which the panel renders too small);
# blueman-applet still runs headless for the agent — see bin/panel/install.sh.
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/../.." && pwd)"
ICON_DIR="$NEXUS_ROOT/assets/panel-icons"
powered=$(bluetoothctl show 2>/dev/null | awk -F': ' '/Powered:/{print $2; exit}')
connected=$(bluetoothctl devices Connected 2>/dev/null | grep -c '^Device')
if [ "$powered" != "yes" ]; then
echo "<img>${ICON_DIR}/bluetooth-disabled.png</img>"
echo "<tool>Bluetooth: off</tool>"
elif [ "$connected" -gt 0 ]; then
names=$(bluetoothctl devices Connected 2>/dev/null | sed 's/^Device [0-9A-F:]* //')
echo "<img>${ICON_DIR}/bluetooth-active.png</img>"
echo "<tool>Bluetooth: connected
${names}</tool>"
else
echo "<img>${ICON_DIR}/bluetooth-online.png</img>"
echo "<tool>Bluetooth: on (no devices connected)</tool>"
fi
echo "<click>blueman-manager</click>"
+87
View File
@@ -0,0 +1,87 @@
#!/bin/bash
# Install NexusOS panel applets — symlink scripts, restore genmon config, wire autostart.
set -e
NEXUS="$HOME/nexus-core"
BIN="$HOME/.local/bin"
AUTOSTART="$HOME/.config/autostart"
PANEL_CFG="$HOME/.config/xfce4/panel"
mkdir -p "$BIN" "$AUTOSTART" "$PANEL_CFG"
# Scripts
for script in network-applet.sh network-popup.py nexus_menu_base.py \
nexus-applet.sh nexus-popup.py bluetooth-applet.sh; do
ln -sf "$NEXUS/bin/panel/$script" "$BIN/$script"
done
chmod +x "$NEXUS/bin/panel/network-popup.py" "$NEXUS/bin/panel/network-applet.sh" \
"$NEXUS/bin/panel/nexus-popup.py" "$NEXUS/bin/panel/nexus-applet.sh" \
"$NEXUS/bin/panel/bluetooth-applet.sh"
# Autostart — popup launchers (symlinks) + nm-tray suppressors (regular files)
ln -sf "$NEXUS/management/autostart/nexus-network-popup.desktop" \
"$AUTOSTART/nexus-network-popup.desktop"
ln -sf "$NEXUS/management/autostart/nexus-popup.desktop" \
"$AUTOSTART/nexus-popup.desktop"
cp -f "$NEXUS/management/autostart/nm-tray.desktop" "$AUTOSTART/nm-tray.desktop"
cp -f "$NEXUS/management/autostart/nm-tray-autostart.desktop" "$AUTOSTART/nm-tray-autostart.desktop"
# Suppress blueman-applet autostart (both the system blueman.desktop and the
# user blueman-applet.desktop). blueman always forces its own tray icon, which
# the panel renders too small; the Bluetooth genmon (plugin-16) replaces it and
# opens blueman-manager on click (which provides the pairing agent on demand).
cp -f "$NEXUS/management/autostart/blueman.desktop" "$AUTOSTART/blueman.desktop"
cp -f "$NEXUS/management/autostart/blueman-applet.desktop" "$AUTOSTART/blueman-applet.desktop"
# Genmon configs (regular files — panel writes back to them)
cp -f "$NEXUS/management/panel/genmon-13.rc" "$PANEL_CFG/genmon-13.rc"
cp -f "$NEXUS/management/panel/genmon-15.rc" "$PANEL_CFG/genmon-15.rc"
cp -f "$NEXUS/management/panel/genmon-16.rc" "$PANEL_CFG/genmon-16.rc"
# ── Register the genmon applets into the XFCE panel ────────────────────────
# The network applet's genmon (plugin-13) was added by hand once; the Nexus
# (15) and Bluetooth (16) applets are wired up programmatically via xfconf so a
# fresh install picks them up. No-op on machines without XFCE (e.g. the WSL box).
# place = before|after — where to insert relative to the network applet (13).
register_genmon() {
local id=$1 place=$2 anchor=13 panel=panel-1
command -v xfconf-query >/dev/null 2>&1 || { echo "xfconf-query not found — skipping panel wiring."; return 0; }
# Declare the plugin's type so the panel knows it's a Generic Monitor.
xfconf-query -c xfce4-panel -p "/plugins/plugin-$id" -t string -s genmon --create
mapfile -t ids < <(xfconf-query -c xfce4-panel -p "/panels/$panel/plugin-ids" 2>/dev/null | grep -E '^[0-9]+$')
if [ ${#ids[@]} -eq 0 ]; then
echo "Panel '$panel' has no plugin-ids array — add plugin-$id manually."; return 0
fi
for e in "${ids[@]}"; do [ "$e" = "$id" ] && { echo "genmon plugin-$id already in panel."; return 0; }; done
local new=() ins=0
for e in "${ids[@]}"; do
[ "$e" = "$anchor" ] && [ "$place" = before ] && [ "$ins" = 0 ] && { new+=("$id"); ins=1; }
new+=("$e")
[ "$e" = "$anchor" ] && [ "$place" = after ] && [ "$ins" = 0 ] && { new+=("$id"); ins=1; }
done
[ "$ins" = 0 ] && new+=("$id") # anchor absent — append
local args=(); for v in "${new[@]}"; do args+=(-t int -s "$v"); done
xfconf-query -c xfce4-panel -p "/panels/$panel/plugin-ids" --force-array "${args[@]}"
echo "genmon plugin-$id registered."
}
register_genmon 15 before # Nexus applet, left of the network applet
register_genmon 16 after # Bluetooth applet, right of the network applet
echo "Reloading panel…"
xfce4-panel -r >/dev/null 2>&1 || true
# Start the popup daemon now so the first click works without a re-login.
# Pin to the system python3 explicitly: PyGObject (gi) is a system package, and
# install.sh is often run from an activated Promethean venv whose python3 lacks
# gi. At login/panel-click time the shebang resolves against the clean system
# PATH (like the network popup), so only this pre-launch needs the hard path.
if command -v xfce4-panel >/dev/null 2>&1; then
pkill -f "bin/.*nexus-popup.py" 2>/dev/null || true
setsid /usr/bin/python3 "$BIN/nexus-popup.py" >/dev/null 2>&1 < /dev/null &
fi
echo "Panel applets installed."
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Genmon network applet — PNG icon + SSID label + hover details
# Referred to as nm-applet
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/../.." && pwd)"
ICON_DIR="$NEXUS_ROOT/assets/panel-icons"
signal_icon() {
local sig=$1
if [ "$sig" -ge 80 ]; then echo "${ICON_DIR}/network-wireless-signal-excellent.png"
elif [ "$sig" -ge 60 ]; then echo "${ICON_DIR}/network-wireless-signal-good.png"
elif [ "$sig" -ge 40 ]; then echo "${ICON_DIR}/network-wireless-signal-ok.png"
elif [ "$sig" -ge 20 ]; then echo "${ICON_DIR}/network-wireless-signal-weak.png"
else echo "${ICON_DIR}/network-wireless-signal-none.png"
fi
}
# Determine the active uplink from the default route rather than NetworkManager's
# connected state: the wired interface (t2_ncm) is unmanaged by NM and never
# reports STATE=connected, so it would otherwise fall through to "offline".
PRIMARY_IF=$(ip route show default 2>/dev/null | awk '/^default/{print $5; exit}')
if [ -n "$PRIMARY_IF" ] && [ -d "/sys/class/net/$PRIMARY_IF/wireless" ]; then
ACTIVE_TYPE=wifi
elif [ -n "$PRIMARY_IF" ]; then
ACTIVE_TYPE=ethernet
else
ACTIVE_TYPE=
fi
POPUP_OPEN=false
[ -f /tmp/nexus-network-popup-visible ] && POPUP_OPEN=true
if [[ "$ACTIVE_TYPE" == "wifi" ]]; then
IFS=: read -r _ SSID SIGNAL < <(nmcli -t --escape no -f ACTIVE,SSID,SIGNAL dev wifi list --rescan no | grep '^yes')
IP=$(nmcli -t --escape no -f IP4.ADDRESS dev show | grep -m1 'IP4.ADDRESS' | cut -d: -f2 | cut -d/ -f1)
echo "<img>$(signal_icon "${SIGNAL:-0}")</img>"
if $POPUP_OPEN; then
echo "<tool></tool>"
else
echo "<tool>Wireless
SSID: ${SSID}
Signal: ${SIGNAL}%
IP: ${IP:-unknown}</tool>"
fi
echo "<click>$HOME/.local/bin/network-popup.py</click>"
elif [[ "$ACTIVE_TYPE" == "ethernet" ]]; then
# t2_ncm is unmanaged, so read the IP straight off the primary interface.
IP=$(ip -4 -o addr show "$PRIMARY_IF" 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -n1)
echo "<img>${ICON_DIR}/network-wired.png</img>"
if $POPUP_OPEN; then
echo "<tool></tool>"
else
echo "<tool>Wired (Ethernet)
Interface: ${PRIMARY_IF:-unknown}
IP: ${IP:-unknown}</tool>"
fi
echo "<click>$HOME/.local/bin/network-popup.py</click>"
else
echo "<img>${ICON_DIR}/network-offline.png</img>"
if $POPUP_OPEN; then
echo "<tool></tool>"
else
echo "<tool>No active network connection</tool>"
fi
echo "<click>$HOME/.local/bin/network-popup.py</click>"
fi
+462
View File
@@ -0,0 +1,462 @@
#!/usr/bin/env python3
"""NexusOS network popup daemon — toggle via SIGUSR1, instant open."""
# PyGObject (gi.repository) is dynamically generated and its API is Optional-heavy
# (e.g. Gdk.Display.get_default() is typed Display|None). These are fine at runtime,
# so silence the type-checker noise for this GTK desktop script.
# pyright: reportMissingModuleSource=false, reportOptionalMemberAccess=false, reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false
import os, sys, signal, threading, subprocess
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import Gtk, Gdk, GLib, GdkPixbuf
from nexus_menu_base import apply_css, get_panel_bottom, get_mouse_position
PID_FILE = "/tmp/nexus-network-popup.pid"
VISIBLE_FILE = "/tmp/nexus-network-popup-visible"
POPUP_CSS = b"""
list { background-color: transparent; }
list row { background-color: transparent; padding: 0; border: none; }
list row:hover { background-color: rgba(140,198,63,0.12); }
list row:selected { background-color: rgba(140,198,63,0.18); }
.net-header { padding: 10px 12px; }
.net-ssid { font-weight: bold; }
.net-ip { color: #a8a8a8; font-size: 0.85em; }
.net-check { color: #8cc63f; font-weight: bold; }
.net-lock { color: #a8a8a8; }
.net-row-box { padding: 7px 8px; }
.footer-btn { padding: 6px 12px; border-radius: 0; border: none;
background-color: transparent; color: #a8a8a8; }
.footer-btn:hover { background-color: rgba(140,198,63,0.12); color: #f2f2f2; }
.vpn-row { padding: 8px 12px; }
.vpn-label { font-size: 0.9em; }
.vpn-status { color: #a8a8a8; font-size: 0.8em; }
switch { background-color: #3a3d41; border-radius: 14px; border: 1px solid #4d3461;
min-width: 42px; min-height: 22px; }
switch:checked { background-color: #8cc63f; border-color: #6ba62a; }
switch slider { background-color: #f2f2f2; border-radius: 50%;
min-width: 16px; min-height: 16px; margin: 2px; }
"""
def nmcli(*args):
try:
return subprocess.check_output(
["nmcli", "-t", "--escape", "no"] + list(args),
text=True, stderr=subprocess.DEVNULL
).strip()
except Exception:
return ""
# This file lives at <repo>/bin/panel/network-popup.py, so the repo root is
# three parents up (panel -> bin -> repo). Using only two parents pointed
# ICON_DIR at the nonexistent bin/assets/panel-icons, so every signal-icon
# load failed and each SSID row fell back to a "?" label.
ICON_DIR = str(Path(__file__).resolve().parent.parent.parent / "assets" / "panel-icons")
def signal_icon(sig):
if sig >= 80: return f"{ICON_DIR}/network-wireless-signal-excellent.png"
if sig >= 60: return f"{ICON_DIR}/network-wireless-signal-good.png"
if sig >= 40: return f"{ICON_DIR}/network-wireless-signal-ok.png"
if sig >= 20: return f"{ICON_DIR}/network-wireless-signal-weak.png"
return f"{ICON_DIR}/network-wireless-signal-none.png"
class NetworkPopup:
def __init__(self):
self.visible = False
self._click_x = None
self.vpn_switch = None
self.vpn_status_lbl = None
self._build_window()
def _on_sig(s, f):
# Capture mouse position NOW (signal handler runs in main thread)
try:
self._click_x = get_mouse_position()[0]
except Exception:
self._click_x = None
GLib.idle_add(self.toggle)
signal.signal(signal.SIGUSR1, _on_sig)
with open(PID_FILE, 'w') as f:
f.write(str(os.getpid()))
def _build_window(self):
self.win = Gtk.Window(type=Gtk.WindowType.POPUP)
self.win.set_type_hint(Gdk.WindowTypeHint.POPUP_MENU)
self.win.set_decorated(False)
self.win.set_skip_taskbar_hint(True)
self.win.set_keep_above(True)
self.win.set_default_size(190, -1)
self.win.connect('key-press-event',
lambda w, e: self.hide() if e.keyval == Gdk.KEY_Escape else None)
def on_button_press(w, event):
wx, wy = w.get_position()
ww, wh = w.get_allocated_width(), w.get_allocated_height()
if (int(event.x_root) < wx or int(event.x_root) >= wx + ww or
int(event.y_root) < wy or int(event.y_root) >= wy + wh):
self.hide()
return False
self.win.connect('button-press-event', on_button_press)
def on_map(w, _):
gdk_win = w.get_window()
if gdk_win:
Gdk.Display.get_default().get_default_seat().grab(
gdk_win, Gdk.SeatCapabilities.ALL, True, None, None, None)
return False
self.win.connect('map-event', on_map)
def on_unmap(w, _):
Gdk.Display.get_default().get_default_seat().ungrab()
self.win.connect('unmap-event', on_unmap)
def toggle(self):
if self.visible:
self.hide()
else:
self.show()
def show(self):
for child in self.win.get_children():
self.win.remove(child)
self.win.add(self._build_content())
self.win.show_all()
self._position()
self.win.present()
self.visible = True
open(VISIBLE_FILE, 'w').close()
def hide(self):
self.win.hide()
self.visible = False
try:
os.remove(VISIBLE_FILE)
except FileNotFoundError:
pass
def _position(self):
mx = self._click_x if self._click_x is not None else get_mouse_position()[0]
screen = Gdk.Screen.get_default()
sw = screen.get_width()
w = 190
panel_bottom = get_panel_bottom()
x = max(4, min(mx - w // 2, sw - w - 4))
self.win.move(x, panel_bottom + 2)
def _build_content(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# ── Header (current connection) ──────────────────────────────
self.header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self.header.get_style_context().add_class("net-header")
spin = Gtk.Spinner(); spin.start()
self.header.pack_start(spin, False, False, 0)
self.header.pack_start(Gtk.Label(label="Loading…"), True, True, 0)
outer.pack_start(self.header, False, False, 0)
outer.pack_start(Gtk.Separator(), False, False, 0)
# ── Network list ─────────────────────────────────────────────
self.listbox = Gtk.ListBox()
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
self.listbox.connect('row-activated', self._on_row_activated)
sw = Gtk.ScrolledWindow()
sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
sw.set_min_content_height(280)
sw.set_max_content_height(480)
sw.set_propagate_natural_height(True)
sw.add(self.listbox)
outer.pack_start(sw, True, True, 0)
outer.pack_start(Gtk.Separator(), False, False, 0)
# ── VPN (WireGuard) toggle ────────────────────────────────────
vpn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
vpn_row.get_style_context().add_class("vpn-row")
vpn_icon = Gtk.Label(label="🔒")
vpn_row.pack_start(vpn_icon, False, False, 0)
vpn_text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
vpn_name = Gtk.Label(label="WireGuard", xalign=0.0)
vpn_name.get_style_context().add_class("vpn-label")
self.vpn_status_lbl = Gtk.Label(label="Checking…", xalign=0.0)
self.vpn_status_lbl.get_style_context().add_class("vpn-status")
vpn_text.pack_start(vpn_name, False, False, 0)
vpn_text.pack_start(self.vpn_status_lbl, False, False, 0)
vpn_row.pack_start(vpn_text, True, True, 0)
self.vpn_switch = Gtk.Switch()
self.vpn_switch.set_valign(Gtk.Align.CENTER)
self._vpn_handler = self.vpn_switch.connect('state-set', self._on_vpn_toggle)
vpn_row.pack_start(self.vpn_switch, False, False, 0)
# Set switch state immediately from sysfs — no nmcli round-trip needed
vpn_up = os.path.exists('/sys/class/net/wgs_client')
self.vpn_switch.handler_block(self._vpn_handler)
self.vpn_switch.set_active(vpn_up)
self.vpn_switch.handler_unblock(self._vpn_handler)
self.vpn_status_lbl.set_text("Connected" if vpn_up else "Disconnected")
outer.pack_start(vpn_row, False, False, 0)
outer.pack_start(Gtk.Separator(), False, False, 0)
# ── Footer ───────────────────────────────────────────────────
btn = Gtk.Button(label="Network Settings")
btn.get_style_context().add_class("footer-btn")
btn.set_relief(Gtk.ReliefStyle.NONE)
btn.connect('clicked', lambda _: (self.hide(),
subprocess.Popen(['nm-connection-editor'])))
outer.pack_start(btn, False, False, 0)
threading.Thread(target=self._fetch, daemon=True).start()
return outer
def _fetch(self):
conn_type, device, current_ssid, ip = "", "", "", ""
devs = nmcli("-f", "TYPE,STATE,DEVICE", "dev")
for line in devs.splitlines():
parts = line.split(":")
if len(parts) >= 2 and parts[1] == "connected":
conn_type = parts[0]
device = parts[2] if len(parts) > 2 else ""
break
if conn_type == "wifi":
raw = nmcli("-f", "ACTIVE,SSID,SIGNAL", "dev", "wifi")
for line in raw.splitlines():
parts = line.split(":")
if parts[0] == "yes" and len(parts) >= 2:
current_ssid = parts[1]
break
ip = (nmcli("-f", "IP4.ADDRESS", "dev", "show")
.split("\n")[0].split(":")[-1].split("/")[0].strip())
elif conn_type == "ethernet":
ip = (nmcli("-f", "IP4.ADDRESS", "dev", "show")
.split("\n")[0].split(":")[-1].split("/")[0].strip())
raw = nmcli("-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list", "--rescan", "no")
seen, networks = set(), []
for line in raw.splitlines():
parts = line.split(":")
if len(parts) < 2: continue
ssid = parts[0].strip()
if not ssid or ssid in seen: continue
seen.add(ssid)
try: sig = int(parts[1])
except: sig = 0
sec = parts[2].strip() if len(parts) > 2 else "Open"
in_use = (parts[3].strip() == "*") if len(parts) > 3 else False
networks.append((ssid, sig, sec, in_use))
networks.sort(key=lambda r: (not r[3], -r[1]))
GLib.idle_add(self._populate, conn_type, device, current_ssid, ip, networks[:20])
def _populate(self, conn_type, device, current_ssid, ip, networks):
# Rebuild header
for child in self.header.get_children():
self.header.remove(child)
if conn_type == "wifi" and current_ssid:
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
lbl_ssid = Gtk.Label(label=current_ssid, xalign=0.0)
lbl_ssid.get_style_context().add_class("net-ssid")
lbl_ip = Gtk.Label(label=ip or "no IP", xalign=0.0)
lbl_ip.get_style_context().add_class("net-ip")
vbox.pack_start(lbl_ssid, False, False, 0)
vbox.pack_start(lbl_ip, False, False, 0)
self.header.pack_start(vbox, True, True, 0)
btn_dis = Gtk.Button(label="Disconnect")
btn_dis.get_style_context().add_class("footer-btn")
btn_dis.set_relief(Gtk.ReliefStyle.NONE)
btn_dis.connect('clicked', lambda _, d=device: (
self.hide(),
subprocess.Popen(["bash", "-c",
f"nmcli dev disconnect {d}; notify-send Network Disconnected"])
))
self.header.pack_start(btn_dis, False, False, 0)
elif conn_type == "ethernet":
lbl = Gtk.Label(label=f"Wired {ip or ''}", xalign=0.0)
self.header.pack_start(lbl, True, True, 0)
else:
lbl = Gtk.Label(label="Not connected", xalign=0.0)
lbl.get_style_context().add_class("net-ip")
self.header.pack_start(lbl, True, True, 0)
self.header.show_all()
for ssid, sig, sec, in_use in networks:
row = Gtk.ListBoxRow()
row.ssid = ssid
row.sec = sec
row.in_use = in_use
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
box.get_style_context().add_class("net-row-box")
try:
pb = GdkPixbuf.Pixbuf.new_from_file_at_size(signal_icon(sig), 16, 16)
img = Gtk.Image.new_from_pixbuf(pb)
except Exception:
img = Gtk.Label(label="?")
box.pack_start(img, False, False, 0)
ssid_lbl = Gtk.Label(label=ssid, xalign=0.0)
ssid_lbl.set_ellipsize(3)
if in_use:
ssid_lbl.get_style_context().add_class("net-ssid")
box.pack_start(ssid_lbl, True, True, 0)
if sec and sec.lower() not in ("open", "--", ""):
lock = Gtk.Label(label="")
lock.get_style_context().add_class("net-lock")
box.pack_start(lock, False, False, 0)
if in_use:
chk = Gtk.Label(label="")
chk.get_style_context().add_class("net-check")
box.pack_start(chk, False, False, 0)
row.add(box)
self.listbox.add(row)
self.listbox.show_all()
# Re-position now that height is known
GLib.idle_add(self._position)
def _on_vpn_toggle(self, switch, state):
threading.Thread(target=self._do_vpn_toggle, args=(state,), daemon=True).start()
return False # let GTK move the switch immediately; revert on failure
def _do_vpn_toggle(self, enable):
action = "up" if enable else "down"
try:
subprocess.check_output(
["nmcli", "connection", action, "wgs_client"],
stderr=subprocess.STDOUT, text=True
)
label = "Connected" if enable else "Disconnected"
subprocess.Popen(["notify-send", "WireGuard", label])
GLib.idle_add(self._apply_vpn_state, enable, label)
except subprocess.CalledProcessError as e:
msg = (e.output.strip().split("\n")[-1] if e.output else "Unknown error")
subprocess.Popen(["notify-send", "-u", "critical", "WireGuard",
f"Failed: {msg}"])
# Revert switch on failure
GLib.idle_add(self._apply_vpn_state, not enable,
"Connected" if not enable else "Disconnected")
def _apply_vpn_state(self, active, label):
if self.vpn_switch:
self.vpn_switch.handler_block(self._vpn_handler)
self.vpn_switch.set_active(active)
self.vpn_switch.handler_unblock(self._vpn_handler)
if self.vpn_status_lbl:
self.vpn_status_lbl.set_text(label)
def _on_row_activated(self, lb, row):
if row.in_use:
return
self.hide()
if row.sec and row.sec.lower() not in ("open", "--", ""):
self._show_password_prompt(row.ssid)
else:
self._do_connect(row.ssid)
def _do_connect(self, ssid, password=None):
def run():
cmd = ["nmcli", "dev", "wifi", "connect", ssid]
if password:
cmd += ["password", password]
try:
subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
subprocess.Popen(["notify-send", "Network", f"Connected to {ssid}"])
except subprocess.CalledProcessError as e:
msg = (e.output.strip().split("\n")[-1]
if e.output else "Unknown error")
subprocess.Popen(["notify-send", "-u", "critical", "Network",
f"Failed to connect to {ssid}:\n{msg}"])
subprocess.Popen(["notify-send", "Network", f"Connecting to {ssid}"])
threading.Thread(target=run, daemon=True).start()
def _show_password_prompt(self, ssid):
dlg = Gtk.Window()
dlg.set_title(f"Connect to {ssid}")
dlg.set_default_size(300, -1)
dlg.set_position(Gtk.WindowPosition.CENTER)
dlg.set_keep_above(True)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.set_margin_top(16); box.set_margin_bottom(16)
box.set_margin_start(16); box.set_margin_end(16)
lbl = Gtk.Label(xalign=0.0)
lbl.set_markup(f'<b>Password for "{ssid}"</b>')
entry = Gtk.Entry()
entry.set_visibility(False)
entry.set_placeholder_text("Enter password…")
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
btn_box.set_halign(Gtk.Align.END)
btn_cancel = Gtk.Button(label="Cancel")
btn_connect = Gtk.Button(label="Connect")
btn_box.pack_start(btn_cancel, False, False, 0)
btn_box.pack_start(btn_connect, False, False, 0)
box.pack_start(lbl, False, False, 0)
box.pack_start(entry, False, False, 0)
box.pack_start(btn_box, False, False, 0)
dlg.add(box)
def on_confirm(_):
pw = entry.get_text()
dlg.destroy()
if pw:
self._do_connect(ssid, pw)
entry.connect("activate", on_confirm)
btn_connect.connect("clicked", on_confirm)
btn_cancel.connect("clicked", lambda _: dlg.destroy())
dlg.connect("key-press-event",
lambda w, e: w.destroy() if e.keyval == Gdk.KEY_Escape else None)
dlg.show_all()
dlg.present()
def main():
# If already running, toggle it and exit
try:
with open(PID_FILE) as f:
pid = int(f.read().strip())
os.kill(pid, signal.SIGUSR1)
sys.exit(0)
except (FileNotFoundError, ProcessLookupError, ValueError):
pass
provider = Gtk.CssProvider()
provider.load_from_data(POPUP_CSS)
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(), provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
apply_css()
NetworkPopup()
Gtk.main()
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Genmon Nexus applet — logo icon whose state tracks the running services,
# left-click opens the control popup. Mirrors network-applet.sh.
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/../.." && pwd)"
ICON_DIR="$NEXUS_ROOT/assets/panel-icons"
# Fast, dependency-free liveness check via bash's built-in /dev/tcp — no curl
# round-trip on every 5s genmon tick.
port_up() {
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && { exec 3>&- 3<&-; return 0; }
return 1
}
state() { port_up "$1" && echo "up" || echo "down"; }
BACKEND=$(state 8000) # Synapse
MEMORY=$(state 8001) # Memory service
FRONTEND=$(state 5173) # Vite frontend
up=0
[ "$BACKEND" = up ] && up=$((up + 1))
[ "$MEMORY" = up ] && up=$((up + 1))
[ "$FRONTEND" = up ] && up=$((up + 1))
if [ "$up" -eq 3 ]; then ICON="$ICON_DIR/nexus-on.png"
elif [ "$up" -eq 0 ]; then ICON="$ICON_DIR/nexus-off.png"
else ICON="$ICON_DIR/nexus-partial.png"
fi
dot() { [ "$1" = up ] && echo "●" || echo "○"; }
echo "<img>$ICON</img>"
echo "<tool>NexusOS — ${up}/3 services up
$(dot "$BACKEND") Synapse backend :8000
$(dot "$MEMORY") Memory service :8001
$(dot "$FRONTEND") Frontend (Vite) :5173</tool>"
echo "<click>$HOME/.local/bin/nexus-popup.py</click>"
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""NexusOS service popup daemon — toggle via SIGUSR1, instant open.
Mirrors network-popup.py: an autostarted daemon holding an override_redirect
window. The genmon applet's <click> re-invokes this script, which signals the
running daemon (SIGUSR1) to toggle the window instead of paying Python startup
on every click.
"""
# PyGObject (gi.repository) is dynamically generated and its API is Optional-heavy
# (e.g. Gdk.Display.get_default() is typed Display|None). These are fine at runtime,
# so silence the type-checker noise for this GTK desktop script.
# pyright: reportMissingModuleSource=false, reportOptionalMemberAccess=false, reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false
import os, sys, signal, socket, threading, subprocess
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
from gi.repository import Gtk, Gdk, GLib
from nexus_menu_base import apply_css, get_panel_bottom, get_mouse_position
PID_FILE = "/tmp/nexus-popup.pid"
VISIBLE_FILE = "/tmp/nexus-popup-visible"
# repo root is three parents up: panel -> bin -> repo
REPO = Path(__file__).resolve().parent.parent.parent
NEXUS_CLI = str(REPO / "management" / "nexus-cli.sh")
CTRL_PANEL = str(REPO / "management" / "controlpanel.py")
VENV_PY = str(REPO / "Promethean" / "bin" / "python")
EDGE_PROFILE = str(Path.home() / ".config" / "nexus-edge")
APP_URL = "http://localhost:5173"
# name, port, nexus-cli flag
SERVICES = [
("Synapse backend", 8000, "--backend"),
("Memory service", 8001, "--memory"),
("Frontend (Vite)", 5173, "--frontend"),
]
WIDTH = 264
POPUP_CSS = b"""
.nx-header { padding: 11px 12px 9px 12px; }
.nx-title { font-weight: bold; font-size: 1.05em; }
.nx-sub { color: #a8a8a8; font-size: 0.82em; }
.svc-row { padding: 8px 12px; }
.svc-name { font-size: 0.95em; }
.svc-port { color: #a8a8a8; font-size: 0.78em; }
.dot-up { color: #8cc63f; }
.dot-down { color: #6b6b6b; }
.master-row { padding: 9px 12px; }
.master-label { font-weight: bold; }
.footer-btn { padding: 9px 12px; border-radius: 0; border: none;
background-color: transparent; color: #cfcfcf; }
.footer-btn:hover { background-color: rgba(140,198,63,0.14); color: #f2f2f2; }
switch { background-color: #3a3d41; border-radius: 14px; border: 1px solid #4d3461;
min-width: 42px; min-height: 22px; }
switch:checked { background-color: #8cc63f; border-color: #6ba62a; }
switch slider { background-color: #f2f2f2; border-radius: 50%;
min-width: 16px; min-height: 16px; margin: 2px; }
"""
def port_up(port, timeout=0.3):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
return s.connect_ex(("127.0.0.1", port)) == 0
except OSError:
return False
finally:
s.close()
class NexusPopup:
def __init__(self):
self.visible = False
self._click_x = None
# widgets keyed by port so background refreshes can update them
self._switches = {} # port -> (Gtk.Switch, handler_id)
self._dots = {} # port -> Gtk.Label
self._master = None
self._master_handler = None
self._sub_lbl = None
self._build_window()
def _on_sig(s, f):
try:
self._click_x = get_mouse_position()[0]
except Exception:
self._click_x = None
GLib.idle_add(self.toggle)
signal.signal(signal.SIGUSR1, _on_sig)
with open(PID_FILE, 'w') as f:
f.write(str(os.getpid()))
# ── window plumbing (mirrors network-popup) ───────────────────────
def _build_window(self):
self.win = Gtk.Window(type=Gtk.WindowType.POPUP)
self.win.set_type_hint(Gdk.WindowTypeHint.POPUP_MENU)
self.win.set_decorated(False)
self.win.set_skip_taskbar_hint(True)
self.win.set_keep_above(True)
self.win.set_default_size(WIDTH, -1)
self.win.connect('key-press-event',
lambda w, e: self.hide() if e.keyval == Gdk.KEY_Escape else None)
def on_button_press(w, event):
wx, wy = w.get_position()
ww, wh = w.get_allocated_width(), w.get_allocated_height()
if (int(event.x_root) < wx or int(event.x_root) >= wx + ww or
int(event.y_root) < wy or int(event.y_root) >= wy + wh):
self.hide()
return False
self.win.connect('button-press-event', on_button_press)
def on_map(w, _):
gdk_win = w.get_window()
if gdk_win:
Gdk.Display.get_default().get_default_seat().grab(
gdk_win, Gdk.SeatCapabilities.ALL, True, None, None, None)
return False
self.win.connect('map-event', on_map)
def on_unmap(w, _):
Gdk.Display.get_default().get_default_seat().ungrab()
self.win.connect('unmap-event', on_unmap)
def toggle(self):
self.hide() if self.visible else self.show()
def show(self):
for child in self.win.get_children():
self.win.remove(child)
self._switches.clear()
self._dots.clear()
self.win.add(self._build_content())
self.win.show_all()
self._sync_state()
self._position()
self.win.present()
self.visible = True
open(VISIBLE_FILE, 'w').close()
def hide(self):
self.win.hide()
self.visible = False
try:
os.remove(VISIBLE_FILE)
except FileNotFoundError:
pass
def _position(self):
mx = self._click_x if self._click_x is not None else get_mouse_position()[0]
sw = Gdk.Screen.get_default().get_width()
panel_bottom = get_panel_bottom()
x = max(4, min(mx - WIDTH // 2, sw - WIDTH - 4))
self.win.move(x, panel_bottom + 2)
# ── content ───────────────────────────────────────────────────────
def _build_content(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Header
head = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
head.get_style_context().add_class("nx-header")
title = Gtk.Label(label="NexusOS", xalign=0.0)
title.get_style_context().add_class("nx-title")
self._sub_lbl = Gtk.Label(label="", xalign=0.0)
self._sub_lbl.get_style_context().add_class("nx-sub")
head.pack_start(title, False, False, 0)
head.pack_start(self._sub_lbl, False, False, 0)
outer.pack_start(head, False, False, 0)
outer.pack_start(Gtk.Separator(), False, False, 0)
# Per-service rows
for name, port, flag in SERVICES:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.get_style_context().add_class("svc-row")
dot = Gtk.Label(label="")
dot.get_style_context().add_class("dot-down")
row.pack_start(dot, False, False, 0)
self._dots[port] = dot
txt = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
nm = Gtk.Label(label=name, xalign=0.0)
nm.get_style_context().add_class("svc-name")
pl = Gtk.Label(label=f":{port}", xalign=0.0)
pl.get_style_context().add_class("svc-port")
txt.pack_start(nm, False, False, 0)
txt.pack_start(pl, False, False, 0)
row.pack_start(txt, True, True, 0)
sw = Gtk.Switch()
sw.set_valign(Gtk.Align.CENTER)
hid = sw.connect('state-set', self._on_service_toggle, flag, port)
self._switches[port] = (sw, hid)
row.pack_start(sw, False, False, 0)
outer.pack_start(row, False, False, 0)
outer.pack_start(Gtk.Separator(), False, False, 0)
# Master "All services" row
master_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
master_row.get_style_context().add_class("master-row")
ml = Gtk.Label(label="All services", xalign=0.0)
ml.get_style_context().add_class("master-label")
master_row.pack_start(ml, True, True, 0)
self._master = Gtk.Switch()
self._master.set_valign(Gtk.Align.CENTER)
self._master_handler = self._master.connect('state-set', self._on_master_toggle)
master_row.pack_start(self._master, False, False, 0)
outer.pack_start(master_row, False, False, 0)
outer.pack_start(Gtk.Separator(), False, False, 0)
# Footer actions
btn_panel = self._footer_button("Control Panel", self._open_control_panel)
btn_app = self._footer_button("Open App", self._open_app)
outer.pack_start(btn_panel, False, False, 0)
outer.pack_start(Gtk.Separator(), False, False, 0)
outer.pack_start(btn_app, False, False, 0)
return outer
def _footer_button(self, label, cb):
btn = Gtk.Button(label=label)
btn.get_style_context().add_class("footer-btn")
btn.set_relief(Gtk.ReliefStyle.NONE)
btn.connect('clicked', lambda _: (self.hide(), cb()))
return btn
# ── state sync ─────────────────────────────────────────────────────
def _sync_state(self):
"""Read live port state and update every switch, dot, and the header."""
up = 0
all_up = True
for _, port, _flag in SERVICES:
alive = port_up(port)
up += 1 if alive else 0
all_up = all_up and alive
self._set_switch(port, alive)
dot = self._dots.get(port)
if dot:
ctx = dot.get_style_context()
ctx.remove_class("dot-up"); ctx.remove_class("dot-down")
ctx.add_class("dot-up" if alive else "dot-down")
if self._master is not None:
self._master.handler_block(self._master_handler)
self._master.set_active(all_up)
self._master.handler_unblock(self._master_handler)
if self._sub_lbl is not None:
n = len(SERVICES)
label = ("All services running" if up == n
else "Stopped" if up == 0
else f"{up}/{n} services running")
self._sub_lbl.set_text(label)
def _set_switch(self, port, active):
entry = self._switches.get(port)
if not entry:
return
sw, hid = entry
sw.handler_block(hid)
sw.set_active(active)
sw.handler_unblock(hid)
# ── actions ────────────────────────────────────────────────────────
def _cli(self, *args):
"""Run nexus-cli.sh, then resync the UI from real port state."""
def run():
try:
subprocess.run(["bash", NEXUS_CLI, *args],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=60)
except Exception:
pass
GLib.idle_add(self._sync_state)
threading.Thread(target=run, daemon=True).start()
def _on_service_toggle(self, switch, state, flag, port):
self._cli("start" if state else "stop", flag)
return False # let GTK move the switch now; _sync_state corrects on failure
def _on_master_toggle(self, switch, state):
self._cli("start" if state else "stop", "all")
return False
def _open_control_panel(self):
try:
subprocess.Popen([VENV_PY, CTRL_PANEL],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
subprocess.Popen(["notify-send", "NexusOS", f"Control Panel failed: {e}"])
def _open_app(self):
# Raise the existing dedicated app window if it's already open, else
# launch a new one. No service lifecycle ownership here (unlike
# nexus-app.sh) — the switches above own start/stop.
try:
already = subprocess.run(
["pgrep", "-f", f"user-data-dir={EDGE_PROFILE}"],
stdout=subprocess.DEVNULL).returncode == 0
cmd = ["microsoft-edge-stable", f"--user-data-dir={EDGE_PROFILE}"]
if not already:
cmd += ["--no-first-run", "--no-default-browser-check",
"--disable-background-mode", "--class=NexusOS"]
cmd += [f"--app={APP_URL}"]
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
subprocess.Popen(["notify-send", "NexusOS", f"Open App failed: {e}"])
def main():
# Already running? Signal it to toggle and exit.
try:
with open(PID_FILE) as f:
pid = int(f.read().strip())
os.kill(pid, signal.SIGUSR1)
sys.exit(0)
except (FileNotFoundError, ProcessLookupError, ValueError):
pass
provider = Gtk.CssProvider()
provider.load_from_data(POPUP_CSS)
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(), provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
apply_css()
NexusPopup()
Gtk.main()
if __name__ == "__main__":
main()
+241
View File
@@ -0,0 +1,241 @@
"""Shared base for NexusOS popup menus — override_redirect window, no WM placement."""
# PyGObject (gi.repository) is dynamically generated and its API is Optional-heavy
# (e.g. Gdk.Display.get_default() is typed Display|None). These are fine at runtime,
# so silence the type-checker noise for this GTK desktop script.
# pyright: reportMissingModuleSource=false, reportOptionalMemberAccess=false, reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false
import gi, fcntl, os, sys, signal, subprocess
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib
NEXUS_CSS = b"""
* { font-family: sans-serif; }
window {
background-color: #2a1d33;
color: #f2f2f2;
border: 1px solid #4d3461;
border-radius: 6px;
}
notebook {
background-color: #2a1d33;
}
notebook > header {
background-color: #1e1526;
border-bottom: 1px solid #4d3461;
border-radius: 6px 6px 0 0;
}
notebook > header > tabs > tab {
color: #a8a8a8;
padding: 6px 16px;
border: none;
background-color: transparent;
border-bottom: 2px solid transparent;
}
notebook > header > tabs > tab:checked {
color: #8cc63f;
border-bottom: 2px solid #8cc63f;
}
notebook > header > tabs > tab:hover:not(:checked) {
color: #f2f2f2;
background-color: rgba(140,198,63,0.10);
}
notebook stack {
background-color: #2a1d33;
}
treeview {
background-color: #1e1526;
color: #f2f2f2;
}
treeview:selected {
background-color: #88008f;
color: #ffffff;
}
treeview:hover {
background-color: rgba(140,198,63,0.14);
}
treeview header button {
background-color: #1e1526;
color: #a8a8a8;
border: none;
border-bottom: 1px solid #4d3461;
box-shadow: none;
}
label { color: #f2f2f2; }
button {
background-color: #2e3236;
color: #f2f2f2;
border: 1px solid #3a3d41;
border-radius: 4px;
padding: 5px 14px;
box-shadow: none;
text-shadow: none;
-gtk-icon-shadow: none;
}
button:hover {
background-color: rgba(140,198,63,0.18);
border-color: #8cc63f;
color: #8cc63f;
}
button:active {
background-color: #8cc63f;
color: #0a0a00;
}
scale trough {
background-color: #1e1526;
border: 1px solid #4d3461;
border-radius: 4px;
min-height: 6px;
}
scale trough highlight {
background-color: #8cc63f;
border-radius: 4px;
}
scale slider {
background-color: #8cc63f;
border: none;
border-radius: 50%;
min-width: 16px;
min-height: 16px;
}
separator {
background-color: #4d3461;
min-height: 1px;
}
scrolledwindow { background-color: #1e1526; }
.section-header {
color: #a8a8a8;
font-size: 0.8em;
padding: 4px 8px 2px 8px;
}
.action-row {
padding: 6px 12px;
border-radius: 4px;
}
.action-row:hover {
background-color: rgba(140,198,63,0.14);
color: #8cc63f;
}
.dim { color: #a8a8a8; }
"""
def apply_css():
provider = Gtk.CssProvider()
provider.load_from_data(NEXUS_CSS)
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
def make_popup_window(width, height):
"""Create an override_redirect popup window — bypasses WM placement entirely."""
win = Gtk.Window(type=Gtk.WindowType.POPUP)
win.set_type_hint(Gdk.WindowTypeHint.POPUP_MENU)
win.set_decorated(False)
win.set_skip_taskbar_hint(True)
win.set_skip_pager_hint(True)
win.set_keep_above(True)
win.set_default_size(width, height)
win.set_resizable(False)
win.connect('key-press-event', lambda w, e: w.destroy() if e.keyval == Gdk.KEY_Escape else None)
def on_button_press(w, event):
wx, wy = w.get_position()
ww = w.get_allocated_width()
wh = w.get_allocated_height()
rx, ry = int(event.x_root), int(event.y_root)
if rx < wx or rx >= wx + ww or ry < wy or ry >= wy + wh:
w.destroy()
return False
win.connect('button-press-event', on_button_press)
def on_map(w, _event):
def do_grab():
gdk_win = w.get_window()
if gdk_win:
seat = Gdk.Display.get_default().get_default_seat()
seat.grab(gdk_win, Gdk.SeatCapabilities.ALL, True, None, None, None)
return False # don't repeat
GLib.timeout_add(150, do_grab)
return False
win.connect('map-event', on_map)
def on_destroy(w):
Gdk.Display.get_default().get_default_seat().ungrab()
win.connect('destroy', on_destroy)
return win
def get_mouse_position():
display = Gdk.Display.get_default()
seat = display.get_default_seat()
_, x, y = seat.get_pointer().get_position()
return x, y
def get_panel_bottom():
"""Return y-coordinate just below the top panel via _NET_WORKAREA."""
try:
out = subprocess.check_output(
['xprop', '-root', '_NET_WORKAREA'],
text=True, stderr=subprocess.DEVNULL
)
nums = [int(n.strip()) for n in out.split('=')[1].split(',')]
return nums[1] # workarea y = where usable area begins (= panel height)
except Exception:
return 40
def position_window(win, width, height):
"""Position popup just below the panel, horizontally centered on click."""
mx, _ = get_mouse_position()
screen = Gdk.Screen.get_default()
sw, sh = screen.get_width(), screen.get_height()
panel_bottom = get_panel_bottom()
x = mx - width // 2
y = panel_bottom + 2
x = max(4, min(x, sw - width - 4))
if height > 0:
y = min(y, sh - height - 4)
win.move(x, y)
def single_instance(lockfile, pidfile):
"""flock-based single instance. Returns lock_fd on success, exits on collision."""
fd = open(lockfile, 'w')
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
try:
with open(pidfile) as f:
os.kill(int(f.read().strip()), signal.SIGTERM)
except Exception:
pass
sys.exit(0)
with open(pidfile, 'w') as f:
f.write(str(os.getpid()))
return fd
def load_icon(path, size=16):
try:
return GdkPixbuf.Pixbuf.new_from_file_at_size(path, size, size)
except Exception:
return None
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# plank-primary-watch.sh
# Keep Plank pinned to the xrandr *primary* monitor.
#
# Plank's monitor='' ("primary") miscomputes its autohide reveal region on
# multi-head layouts, so we write the explicit connector name into Plank's
# `monitor` gsetting and restart Plank whenever the primary output changes
# OR its geometry (resolution/position) changes — either invalidates Plank's
# cached reveal region.
PLANK_PATH="net.launchpad.plank.dock.settings:/net/launchpad/plank/docks/dock1/"
# Connector name of the primary output, e.g. "DisplayPort-9".
primary_name() {
xrandr --query | awk '/ connected primary/ {print $1; exit}'
}
# Change key: name + "WxH+X+Y" geometry. Changes if the primary moves/resizes.
primary_key() {
xrandr --query | awk '/ connected primary/ {print $1, $4; exit}'
}
set_monitor() {
# $1 = connector name; only write if it differs (avoids needless restarts)
local want="$1" have
have="$(gsettings get "$PLANK_PATH" monitor 2>/dev/null | tr -d \"\')"
[ "$have" = "$want" ] || gsettings set "$PLANK_PATH" monitor "$want"
}
start_plank() {
pgrep -x plank >/dev/null || setsid plank >/dev/null 2>&1 &
}
restart_plank() {
pkill -x plank
sleep 0.5
setsid plank >/dev/null 2>&1 &
}
last="$(primary_key)"
name="$(primary_name)"
[ -n "$name" ] && set_monitor "$name"
start_plank
while true; do
sleep 3
cur="$(primary_key)"
if [ -n "$cur" ] && [ "$cur" != "$last" ]; then
last="$cur"
set_monitor "$(primary_name)"
restart_plank
elif ! pgrep -x plank >/dev/null; then
# Plank died (crash, manual kill) — bring it back.
start_plank
fi
done
+120
View File
@@ -0,0 +1,120 @@
#!/bin/bash
# The Linux-only half of a restore: system packages, the bundled Ollama binary,
# and the XFCE desktop wiring (panel, wallpaper, theme, terminal, branding).
# None of it means anything on Windows, which is why it lives here instead of in
# bin/sync.py — sync.py owns the portable half and calls this in two stages:
#
# restore-linux.sh prep Before the rebuild: system packages the build needs.
# restore-linux.sh desktop After the rebuild: Ollama binary + desktop wiring.
#
# Not meant to be run by hand — use `ncp restore` (python bin/sync.py restore).
# Set by bin/sync.py to the repo it was invoked from, so a clone in a scratch dir
# operates on itself instead of reaching into the real ~/nexus-core.
NEXUS_ROOT="${NEXUS_ROOT:-$HOME/nexus-core}"
stage="${1:?usage: restore-linux.sh prep|desktop}"
cd "$NEXUS_ROOT" || { echo "No $NEXUS_ROOT"; exit 1; }
if [ "$stage" = "prep" ]; then
# Fresh machine: the system packages the desktop + build steps need. No-op once
# installed, so it's cheap on an in-place restore. Skipped without apt (non-Debian).
if command -v apt-get >/dev/null; then
echo "Installing system packages..."
sudo apt-get install -y --no-install-recommends \
git sqlite3 curl python3-venv python3-gi gir1.2-gtk-3.0 \
nodejs npm xfce4-genmon-plugin plank blueman \
|| echo "Warning: some system packages failed — install them manually."
fi
exit 0
fi
echo ""
echo "Ensuring Ollama binary..."
# ollama/ is gitignored, so a fresh clone has no binary — fetch it. No-op if the
# machine already has one (e.g. an in-place restore).
if [ -x "$NEXUS_ROOT/bin/fetch-ollama.sh" ]; then
"$NEXUS_ROOT/bin/fetch-ollama.sh" "$NEXUS_ROOT" || echo "Warning: Ollama fetch failed — install it manually."
else
echo "Warning: bin/fetch-ollama.sh not found — skipping Ollama fetch."
fi
# Panel layout, wallpaper, compositing, keybindings and terminal profile all live
# in the xfconf channel XMLs — restore them by copying the files back. xfconfd
# caches channels in memory and rewrites them on exit, so it has to die first or
# it clobbers what we just copied; the next xfconf-query respawns it.
xml_snap="$NEXUS_ROOT/assets/themes/restore-snapshot/xfconf-xml"
xml_dst="$HOME/.config/xfce4/xfconf/xfce-perchannel-xml"
if [ -d "$xml_snap" ] && command -v xfconf-query >/dev/null; then
echo ""
echo "Restoring XFCE desktop settings (panel, wallpaper, effects)..."
mkdir -p "$xml_dst"
pkill -x xfconfd 2>/dev/null && sleep 1
cp -f "$xml_snap"/*.xml "$xml_dst/" 2>/dev/null
# Wallpaper props are keyed by monitor name, which differs per machine — point
# every backdrop this box actually has at the Nexus background.
bg="$NEXUS_ROOT/assets/background.png"
if [ -f "$bg" ] && [ -n "${DISPLAY:-}" ]; then
xfconf-query -c xfce4-desktop -l 2>/dev/null | grep 'last-image$' | while read -r p; do
xfconf-query -c xfce4-desktop -p "$p" -s "$bg" 2>/dev/null || true
done
fi
fi
# Multi-monitor primary-follow watcher (panel + Plank track the xrandr primary).
if [ -f "$NEXUS_ROOT/bin/panel/plank-primary-watch.sh" ]; then
mkdir -p "$HOME/.local/bin"
chmod +x "$NEXUS_ROOT/bin/panel/plank-primary-watch.sh"
ln -sf "$NEXUS_ROOT/bin/panel/plank-primary-watch.sh" "$HOME/.local/bin/plank-primary-watch.sh"
fi
plank_snap="$NEXUS_ROOT/assets/themes/restore-snapshot/plank"
if [ -d "$plank_snap" ]; then
mkdir -p "$HOME/.config/plank"
cp -rf "$plank_snap/." "$HOME/.config/plank/" # /. = contents, else it nests
fi
echo ""
echo "Restoring NexusOS desktop theme..."
theme_installer="$NEXUS_ROOT/assets/themes/install-theme.sh"
if [ -x "$theme_installer" ]; then
"$theme_installer"
elif [ -f "$theme_installer" ]; then
bash "$theme_installer"
else
echo "Warning: $theme_installer not found — skipping theme restore."
fi
echo ""
echo "Installing Promethean Terminal launcher..."
term_installer="$NEXUS_ROOT/bin/promethean/install.sh"
if [ -x "$term_installer" ]; then
"$term_installer"
else
echo "Warning: $term_installer not found — skipping."
fi
echo ""
echo "Installing panel applet..."
panel_installer="$NEXUS_ROOT/bin/panel/install.sh"
if [ -x "$panel_installer" ]; then
"$panel_installer"
else
echo "Warning: $panel_installer not found — skipping panel install."
fi
# Distro branding — the About dialog / neofetch read /etc/os-release.
if grep -q '^ID=linuxmint' /etc/os-release 2>/dev/null && ! grep -q 'NexusOS' /etc/os-release; then
echo ""
echo "Branding /etc/os-release as NexusOS..."
sudo sed -i -e 's/^NAME=.*/NAME="NexusOS"/' -e 's/^PRETTY_NAME=.*/PRETTY_NAME="NexusOS 1.0"/' \
/etc/os-release || echo "Warning: os-release branding skipped."
fi
# The AMD box runs CPU-only (Vega 20, 4GB VRAM thrashes). An NVIDIA box should not.
if command -v nvidia-smi >/dev/null && \
[ "$(sqlite3 "$NEXUS_ROOT/synapse/memory/memory.db" \
"select value from settings where key='memory_gpu_offload'" 2>/dev/null)" = "0" ]; then
echo "Note: memory_gpu_offload=0 came from the AMD box (4GB VRAM). This machine has an"
echo " NVIDIA GPU — raise it in Settings to actually use the card."
fi
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""NexusOS backup and restore - one entry point for Linux and native Windows.
Same command on both boxes. Everything portable lives here: the git sync with
Gitea, the memory-DB dump / compare / rebuild, and the venv + web-UI rebuild.
The parts that only mean something on Linux - apt packages, the bundled Ollama
binary, the XFCE desktop wiring, the desktop snapshot - stay in bash and get
called from here, skipped outright on Windows.
python bin/sync.py restore [--check]
python bin/sync.py backup [--check] [--full] [--force-db]
python bin/sync.py compare # print the direction verdict only
Standard library only, so it runs before the Promethean venv exists and needs no
sqlite3 binary on PATH - Windows has none, which is why the old
bin/db-compare.sh could never work there.
A fresh machine clones first (git clone <repo> nexus-core), then runs this.
"""
import argparse
import os
import shutil
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "synapse" / "memory" / "memory.db"
DB_SQL = ROOT / "synapse" / "memory" / "memory.db.sql"
DB_SQL_REL = "synapse/memory/memory.db.sql" # git paths are always posix-style
def run(*args, capture=False, check=False):
"""Run a command from the repo root. Returns CompletedProcess."""
exe = shutil.which(args[0]) # resolves npm -> npm.cmd on Windows
if exe is None:
raise SystemExit(f"'{args[0]}' not found on PATH")
return subprocess.run(
[exe, *args[1:]], cwd=ROOT, check=check,
capture_output=capture, text=True, encoding="utf-8",
)
def linux_stage(script: str, *args) -> None:
"""Run one of the Linux-only bash stages. A no-op on Windows, where apt,
xfconf, plank and the rest have nothing to act on."""
if os.name == "nt":
return
path = ROOT / "bin" / script
bash = shutil.which("bash")
if path.exists() and bash:
# Pin the stage to THIS repo. It defaults to ~/nexus-core otherwise, so a
# clone in a scratch dir would restore over the real machine instead.
subprocess.run([bash, str(path), *args], cwd=ROOT, check=False,
env={**os.environ, "NEXUS_ROOT": str(ROOT)})
def venv_python() -> Path:
"""Path to the Promethean interpreter, creating the venv if it's missing."""
venv = ROOT / "Promethean"
py = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
if not py.exists():
subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
return py
def requirements() -> str:
"""Pick the PyTorch overlay for this host."""
if os.name == "nt":
return "requirements-wsl.txt" # CPU / pure-Python, right for native Windows
if shutil.which("nvidia-smi"):
return "requirements-nvidia.txt"
lspci = shutil.which("lspci")
if lspci and "nvidia" in subprocess.run(
[lspci], capture_output=True, text=True).stdout.lower():
return "requirements-nvidia.txt"
return "requirements-amd.txt"
# -- memory DB -----------------------------------------------------------------
def dump_db() -> bool:
"""Dump the (gitignored, binary, WAL) memory DB to a diff-friendly SQL file
so git backs up the assistant's memory + conversation history. sqlite3 reads
through the WAL, so the dump has the latest committed data uncheckpointed."""
if not DB.exists():
return False
try:
with sqlite3.connect(f"file:{DB}?mode=ro", uri=True) as conn:
DB_SQL.write_text("\n".join(conn.iterdump()) + "\n", encoding="utf-8")
except sqlite3.Error as exc:
print(f"Warning: could not dump {DB} ({exc}) - DB not captured")
return False
print(f"Dumped memory DB -> {DB_SQL}")
return True
def _state(conn):
"""What a machine holds: conversations as {id: updated_at} and memory facts
as a set of ids. NOT messages.id - it's INTEGER AUTOINCREMENT, so both
machines hand out the same ids independently and comparing them is
meaningless. A new message bumps its conversation's updated_at, which is
what actually gets caught here."""
def query(sql):
try:
return conn.execute(sql).fetchall()
except sqlite3.Error:
return [] # table absent in an old dump - treat as empty
return (
dict(query("select id, updated_at from conversations")),
{row[0] for row in query("select id from memory")},
)
def _extra(a, b) -> int:
"""How much `a` holds that `b` lacks: conversations `b` is missing or has an
older copy of, plus memory facts `b` doesn't have at all. An out-of-date
copy is NOT extra content on b's side - that asymmetry is what separates
'one box is simply ahead' from a real divergence."""
a_conv, a_mem = a
b_conv, b_mem = b
newer = sum(1 for cid, ts in a_conv.items() if b_conv.get(cid, "") < ts)
return newer + len(a_mem - b_mem)
def compare(db: Path = DB, dump: Path = DB_SQL) -> str:
"""Which way the sync should go. One of: same, local-ahead, local-behind,
diverged, no-live, no-dump.
ponytail: detects direction, does not merge. Diverged is reported, not resolved."""
if not dump.exists() or not dump.stat().st_size:
return "no-dump"
if not db.exists():
return "no-live"
try:
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as live_conn:
live = _state(live_conn)
with sqlite3.connect(":memory:") as dump_conn:
dump_conn.executescript(dump.read_text(encoding="utf-8"))
backup = _state(dump_conn)
except (sqlite3.Error, OSError):
return "diverged" # can't tell: fail safe, refuse both directions
ahead, behind = _extra(live, backup), _extra(backup, live)
if ahead and behind:
return "diverged"
if ahead:
return "local-ahead"
if behind:
return "local-behind"
return "same"
def restore_db() -> None:
"""Rebuild the memory DB from the pulled dump, keeping a rollback copy."""
print()
state = compare()
if state == "same":
print("Memory DB already matches the backup.")
return
if state == "local-ahead":
print("WARNING: this machine has conversations the backup doesn't.")
print(" Not applying the dump - run `backup` HERE first.")
print(f" To discard local history instead: delete {DB} and restore")
return
if state == "diverged":
print("WARNING: this machine and the backup each have conversations the other lacks.")
print(" Not applying the dump - nothing is merging these automatically.")
print(f" Keep local: run `backup` here. Keep remote: delete {DB} and restore")
return
if state == "no-dump":
print("No memory dump in the backup - skipping DB restore.")
return
print("Restoring memory DB from backup...")
rollback = DB.with_suffix(".db.pre-restore")
if DB.exists():
shutil.copyfile(DB, rollback)
for suffix in ("", "-wal", "-shm"):
Path(str(DB) + suffix).unlink(missing_ok=True)
try:
with sqlite3.connect(DB) as conn:
conn.executescript(DB_SQL.read_text(encoding="utf-8"))
print("Memory DB restored (conversations + history + facts).")
except sqlite3.Error as exc:
print(f"Warning: memory DB rebuild failed ({exc}).")
if rollback.exists():
shutil.move(rollback, DB)
print("Rolled back to previous DB.")
# -- rebuild -------------------------------------------------------------------
def rebuild_env() -> None:
print("\nRebuilding Python environment...")
py, req = venv_python(), requirements()
if (ROOT / req).exists():
subprocess.run([str(py), "-m", "pip", "install", "--upgrade", "pip", "-q"], check=False)
subprocess.run([str(py), "-m", "pip", "install", "-r", req], cwd=ROOT, check=False)
else:
print(f"Warning: {req} not found - skipping pip install.")
print("\nRebuilding frontend dependencies...")
web = ROOT / "interface" / "web"
npm = shutil.which("npm")
if npm is None:
print("Warning: npm not found - the web UI will not be built.")
return
subprocess.run([npm, "install"], cwd=web, check=False)
# Build the UI so the backend can serve it single-process (it mounts
# interface/web/dist at :8000). Without this the app has no UI to show.
print("Building frontend...")
subprocess.run([npm, "run", "build"], cwd=web, check=False)
if not (web / "dist" / "index.html").exists():
print("Warning: interface/web/dist/index.html missing - the app will serve no UI.")
# -- commands ------------------------------------------------------------------
def cmd_restore(args) -> int:
if args.check:
print("Fetching to preview restore (no changes)...")
run("git", "fetch", "origin")
print("\nCommits a restore would apply:")
print(run("git", "log", "--oneline", "..origin/main", capture=True).stdout or "(up to date)")
print(run("git", "diff", "--stat", "..origin/main", capture=True).stdout)
print(f"Memory DB vs backup: {compare()}")
print("\n(dry-run only - nothing changed. Apply with: restore)")
return 0
# System packages first - the venv and npm build below need them present.
linux_stage("restore-linux.sh", "prep")
print("\nPulling latest from Gitea...")
if run("git", "pull", "--ff-only", "origin", "main").returncode:
print("Pull failed (diverged? stash/commit local changes).")
return 1
restore_db()
rebuild_env()
linux_stage("restore-linux.sh", "desktop")
print("\nRestore complete. Nexus is ready to start.")
print("Note: Ollama models are not in the backup - pull them with `ollama pull <model>`.")
return 0
def _remote_dump(tmp: Path) -> bool:
"""Write origin/main's dump to tmp. False if it can't be fetched."""
if run("git", "fetch", "-q", "origin").returncode:
print("Note: could not reach Gitea - skipping backup safety check.")
return False
shown = run("git", "show", f"origin/main:{DB_SQL_REL}", capture=True)
if shown.returncode:
return False
tmp.write_text(shown.stdout, encoding="utf-8")
return True
def check_db_direction(tmp_dir: Path) -> bool:
"""Mirror of restore's guard: refuse to dump a stale live DB over a backup
that already holds newer conversations from the other machine. Compares
against the REMOTE dump, not the working-tree copy - that copy is from this
box's last backup and is exactly what goes stale when the other box pushes."""
if not DB.exists():
return True
tmp = tmp_dir / "remote.sql"
if not _remote_dump(tmp):
return True
state = compare(DB, tmp)
if state == "local-behind":
print("REFUSING TO BACK UP: the backup has conversations this machine doesn't.")
print(" Backing up now would overwrite them with this box's older history.")
print(" Run `restore` here first, then back up.")
return False
if state == "diverged":
print("REFUSING TO BACK UP: this machine and the backup each have conversations")
print(" the other lacks. Nothing merges these automatically.")
print(" Force this box's history to win: python bin/sync.py backup --force-db")
return False
return True
def cmd_backup(args) -> int:
import tempfile
if args.full:
linux_stage("backup-linux.sh")
dump_db()
with tempfile.TemporaryDirectory() as tmp_dir:
safe = check_db_direction(Path(tmp_dir))
if args.check:
print(f"\nMemory DB vs backup: {'safe to back up' if safe else 'STALE - restore first'}")
print("\nFiles a backup would commit:")
print(run("git", "status", "--short", capture=True).stdout)
print("Local commits not yet pushed:")
print(run("git", "log", "--oneline", "@{u}..", capture=True).stdout or "(none / no upstream)")
print("(dry-run only - nothing committed or pushed. Apply with: backup)")
return 0
if not safe and not args.force_db:
return 1
run("git", "add", "-A")
if run("git", "diff", "--cached", "--quiet").returncode:
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
if run("git", "commit", "-q", "-m", f"backup: {stamp}").returncode:
print("Commit failed - see error above (e.g. git identity not set).")
return 1
print("Committed backup snapshot.")
else:
print("No changes to commit.")
print("Pushing to Gitea...")
if run("git", "push", "origin", "main").returncode:
print("Push failed. Set up credentials once with:")
print(" git config credential.helper store # then push once and enter your Gitea token")
return 1
print("Backup complete.")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
restore = sub.add_parser("restore", help="pull from Gitea, rebuild DB + venv + web UI")
restore.add_argument("-c", "--check", action="store_true", help="dry run, change nothing")
restore.set_defaults(func=cmd_restore)
backup = sub.add_parser("backup", help="dump DB, commit and push to Gitea")
backup.add_argument("-c", "--check", action="store_true", help="dry run, change nothing")
backup.add_argument("-f", "--full", action="store_true",
help="also snapshot the live desktop wiring + Claude notes (Linux)")
backup.add_argument("--force-db", action="store_true", help="skip the staleness guard")
backup.set_defaults(func=cmd_backup)
compare_cmd = sub.add_parser("compare", help="print the sync direction verdict")
compare_cmd.set_defaults(func=lambda a: (print(compare()), 0)[1])
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())