refactor(management): replace curses TUIs with API-backed ncp subcommands; panel/autostart/install integration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -0,0 +1,8 @@
|
|||||||
|
# Promethean Terminal shell init
|
||||||
|
[[ -f ~/.bashrc ]] && source ~/.bashrc
|
||||||
|
export VIRTUAL_ENV_DISABLE_PROMPT=1
|
||||||
|
if [[ -f "$HOME/nexus-core/Promethean/bin/activate" ]]; then
|
||||||
|
source "$HOME/nexus-core/Promethean/bin/activate"
|
||||||
|
fi
|
||||||
|
export PS1='\[\e[0;35m\](Promethean)\[\e[0m\] ${debian_chroot:+($debian_chroot)}\[\e[0;37m\]\u@\h\[\e[0m\]:\[\e[1;32m\]\w\[\e[0m\]\$ '
|
||||||
|
printf '\e]0;Promethean Terminal\a'
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
NEXUS_ROOT="$HOME/nexus-core"
|
|
||||||
|
|
||||||
rsync -avz --delete \
|
|
||||||
--exclude='Promethean/' \
|
|
||||||
--exclude='models/blobs/' \
|
|
||||||
--exclude='ollama/' \
|
|
||||||
--exclude='management/Mint-Y-Nexus/' \
|
|
||||||
--exclude='interface/web/node_modules/' \
|
|
||||||
--exclude='interface/web/dist/' \
|
|
||||||
--exclude='runtime/' \
|
|
||||||
--exclude='__pycache__/' \
|
|
||||||
--exclude='*.pyc' \
|
|
||||||
-e ssh \
|
|
||||||
"$NEXUS_ROOT/" "router:/tmp/mnt/Wingdrive2/nexus-backup/"
|
|
||||||
@@ -1,26 +1,93 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Snapshot the live NexusOS desktop wiring into the repo before mirroring,
|
# Backup NexusOS to the router. All modes rsync into the canonical nexus-core
|
||||||
# so the backup captures the ACTUAL current state (not just the canonical
|
# folder over the normal SSH daemon (host: router, port 9001) — the SAME folder
|
||||||
# defaults baked into assets/themes/install-theme.sh). This is a reference
|
# restore.sh pulls from, so backup and restore stay in sync.
|
||||||
# copy for recovery/diffing; install-theme.sh remains the applier on restore.
|
#
|
||||||
snap="$HOME/nexus-core/assets/themes/restore-snapshot"
|
# backup.sh Fast incremental backup.
|
||||||
mkdir -p "$snap"
|
# backup.sh full Same, but first snapshots the live desktop wiring
|
||||||
{
|
# and retains runtime/ (minus logs) for a fuller copy.
|
||||||
echo "# NexusOS live desktop wiring — snapshot $(date -Is)"
|
# backup.sh -c | --claude Dry-run: show what a backup WOULD push (content-
|
||||||
echo "# Reference only. Restore is done by assets/themes/install-theme.sh"
|
# level), change nothing. For deciding whether an
|
||||||
for p in /Net/ThemeName /Net/IconThemeName /Gtk/CursorThemeName \
|
# actual backup is needed.
|
||||||
/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
|
|
||||||
|
|
||||||
lftp sftp://router:2022 << 'LFTP'
|
NEXUS_ROOT="$HOME/nexus-core"
|
||||||
set sftp:connect-program "ssh -a -x -p 2022"
|
DEST="router:/tmp/mnt/Wingdrive2/nexus-core/"
|
||||||
set cmd:interactive yes
|
mode="$1"
|
||||||
mirror --reverse --delete --verbose --dereference --exclude "^Promethean/" --exclude "^models/blobs/" --exclude "^ollama/" --exclude "^interface/web/node_modules/" --exclude "^interface/web/dist/" --exclude "^runtime/logs/" --exclude "runtime/backend\.log$" --exclude "runtime/frontend\.log$" --exclude "__pycache__" --exclude "\.pyc$" /home/jon/nexus-core /Wingdrive2/nexus-backup
|
|
||||||
quit
|
# Standard (incremental) excludes — shared by the default backup AND the
|
||||||
LFTP
|
# -c/--claude check, so the dry-run accurately previews a real backup.
|
||||||
|
EXCLUDES=(
|
||||||
|
--exclude='.git/'
|
||||||
|
--exclude='Promethean/'
|
||||||
|
--exclude='models/blobs/'
|
||||||
|
--exclude='ollama/'
|
||||||
|
--exclude='interface/web/node_modules/'
|
||||||
|
--exclude='interface/web/dist/'
|
||||||
|
--exclude='runtime/'
|
||||||
|
--exclude='__pycache__/'
|
||||||
|
--exclude='*.pyc'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check mode (-c|--claude|check): content-level dry-run, no transfer. Lists what
|
||||||
|
# a standard backup would push to the router (and delete there). --checksum
|
||||||
|
# compares by content (not size/mtime), so only genuine differences show.
|
||||||
|
if [ "$mode" = "-c" ] || [ "$mode" = "--claude" ] || [ "$mode" = "check" ]; then
|
||||||
|
echo "Checking backup diff vs router (dry-run, content-level, no changes)..."
|
||||||
|
rsync -rlni --checksum --delete "${EXCLUDES[@]}" -e ssh "$NEXUS_ROOT/" "$DEST"
|
||||||
|
echo ""
|
||||||
|
echo "(dry-run only — nothing pushed. Apply with: backup or backup full)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$mode" = "full" ] || [ "$mode" = "-f" ]; then
|
||||||
|
# Snapshot the live NexusOS desktop wiring into the repo before backing up,
|
||||||
|
# so the backup captures the ACTUAL current state (not just the canonical
|
||||||
|
# defaults baked into assets/themes/install-theme.sh). This is a reference
|
||||||
|
# copy for recovery/diffing; install-theme.sh remains the applier on restore.
|
||||||
|
snap="$NEXUS_ROOT/assets/themes/restore-snapshot"
|
||||||
|
mkdir -p "$snap"
|
||||||
|
{
|
||||||
|
echo "# NexusOS live desktop wiring — snapshot $(date -Is)"
|
||||||
|
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
|
||||||
|
|
||||||
|
# Symlink the Claude memory notes (machine knowledge that lives outside the
|
||||||
|
# repo under ~/.claude) into assets/notes/ so the backup below captures them.
|
||||||
|
# rsync runs with -L (copy-links), so the links resolve to real content on
|
||||||
|
# the backup target. Relative links + a loop so new notes are picked up too.
|
||||||
|
# Some of these notes aren't Nexus-specific (Fusion 360, distro/Wine).
|
||||||
|
notes_src="$HOME/.claude/projects/-home-jon-nexus-core/memory"
|
||||||
|
notes_dst="$NEXUS_ROOT/assets/notes"
|
||||||
|
mkdir -p "$notes_dst"
|
||||||
|
for f in "$notes_src"/*.md; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
ln -sfr "$f" "$notes_dst/$(basename "$f")"
|
||||||
|
done
|
||||||
|
|
||||||
|
rsync -avzL --no-owner --no-group --delete \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='Promethean/' \
|
||||||
|
--exclude='models/blobs/' \
|
||||||
|
--exclude='ollama/' \
|
||||||
|
--exclude='interface/web/node_modules/' \
|
||||||
|
--exclude='interface/web/dist/' \
|
||||||
|
--exclude='runtime/logs/' \
|
||||||
|
--exclude='runtime/backend.log' \
|
||||||
|
--exclude='runtime/frontend.log' \
|
||||||
|
--exclude='__pycache__/' \
|
||||||
|
--exclude='*.pyc' \
|
||||||
|
-e ssh \
|
||||||
|
"$NEXUS_ROOT/" "$DEST"
|
||||||
|
else
|
||||||
|
rsync -avz --no-owner --no-group --delete "${EXCLUDES[@]}" -e ssh "$NEXUS_ROOT/" "$DEST"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import re, subprocess, sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
NEXUS_ROOT = Path.home() / "nexus-core"
|
NEXUS_ROOT = Path.home() / "nexus-core"
|
||||||
NVIDIA_REQS = NEXUS_ROOT / "nvidia_requirements.txt"
|
NVIDIA_REQS = NEXUS_ROOT / "requirements-nvidia.txt"
|
||||||
|
|
||||||
CUDA_TO_WHEEL = [
|
CUDA_TO_WHEEL = [
|
||||||
((12, 8), "cu128"),
|
((12, 8), "cu128"),
|
||||||
|
|||||||
@@ -1,109 +1,87 @@
|
|||||||
#!/bin/bash
|
#!/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"
|
NEXUS_ROOT="$HOME/nexus-core"
|
||||||
ROUTER_BACKUP="router:/tmp/mnt/Wingdrive2/nexus-backup/"
|
ROUTER_BACKUP="router:/tmp/mnt/Wingdrive2/nexus-core/"
|
||||||
|
|
||||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
is_wsl() {
|
usage() {
|
||||||
grep -qi microsoft /proc/version 2>/dev/null
|
cat <<EOF
|
||||||
|
Usage: install.sh [-w|--windows] [-h|--help]
|
||||||
|
|
||||||
|
(no flags) Run the Linux install.
|
||||||
|
-w, --windows Run the Windows installer (install-windows.ps1), which sets up
|
||||||
|
WSL2 and bootstraps NexusOS inside it.
|
||||||
|
-h, --help Show this help.
|
||||||
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
detect_requirements() {
|
detect_requirements() {
|
||||||
if command -v nvidia-smi &>/dev/null && nvidia-smi &>/dev/null 2>&1; then
|
if command -v nvidia-smi &>/dev/null && nvidia-smi &>/dev/null 2>&1; then
|
||||||
echo "nvidia_requirements.txt"
|
echo "requirements-nvidia.txt"
|
||||||
elif lspci 2>/dev/null | grep -qi nvidia; then
|
elif lspci 2>/dev/null | grep -qi nvidia; then
|
||||||
echo "nvidia_requirements.txt"
|
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
|
else
|
||||||
echo "amd_requirements.txt"
|
echo "requirements-wsl.txt"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
get_windows_user() {
|
run_windows() {
|
||||||
powershell.exe -command '$env:USERNAME' 2>/dev/null | tr -d '\r\n'
|
local ps1="$NEXUS_ROOT/install-windows.ps1"
|
||||||
}
|
if [ ! -f "$ps1" ]; then
|
||||||
|
echo "Error: $ps1 not found." >&2
|
||||||
setup_windows() {
|
exit 1
|
||||||
local win_user distro icon_src icon_win_dir icon_win_path wt_settings
|
|
||||||
|
|
||||||
win_user=$(get_windows_user)
|
|
||||||
distro="${WSL_DISTRO_NAME:-$(uname -n)}"
|
|
||||||
|
|
||||||
if [ -z "$win_user" ] || [ ! -d "/mnt/c/Users/$win_user" ]; then
|
|
||||||
echo "Could not locate Windows user directory — skipping Windows setup."
|
|
||||||
return 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy icon to Windows filesystem
|
# install-windows.ps1 must run from Windows: it self-elevates to Administrator
|
||||||
icon_src="$NEXUS_ROOT/assets/nexus-terminal.png"
|
# and provisions WSL2 from the outside, then runs wsl-bootstrap.sh inside WSL.
|
||||||
icon_win_dir="/mnt/c/Users/$win_user/AppData/Local/Nexus"
|
# Running it from within a WSL shell would be circular, so just point the way.
|
||||||
mkdir -p "$icon_win_dir"
|
cat <<EOF
|
||||||
cp "$icon_src" "$icon_win_dir/nexus-terminal.png"
|
The Windows installer must be run from Windows, not from this shell.
|
||||||
icon_win_path="C:\\Users\\$win_user\\AppData\\Local\\Nexus\\nexus-terminal.png"
|
|
||||||
|
|
||||||
# Create combined init file so WT session has ncp, nvm, and Promethean prompt
|
1. Open the nexus-core folder in Windows Explorer.
|
||||||
cat > "$HOME/.promethean_init" << 'EOF'
|
2. Right-click install-windows.ps1 → "Run with PowerShell".
|
||||||
[[ -f ~/.bashrc ]] && source ~/.bashrc
|
|
||||||
[[ -f ~/.promethean_bashrc ]] && source ~/.promethean_bashrc
|
It will self-elevate, set up WSL2, copy the repo in, and run the bootstrap.
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Patch Windows Terminal settings.json
|
|
||||||
python3 - "$win_user" "$distro" "$icon_win_path" << 'PYEOF'
|
|
||||||
import json, uuid, sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
win_user, distro, icon_path = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
||||||
|
|
||||||
wt_candidates = [
|
|
||||||
f"/mnt/c/Users/{win_user}/AppData/Local/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json",
|
|
||||||
f"/mnt/c/Users/{win_user}/AppData/Local/Packages/Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe/LocalState/settings.json",
|
|
||||||
]
|
|
||||||
|
|
||||||
settings_path = next((Path(p) for p in wt_candidates if Path(p).exists()), None)
|
|
||||||
|
|
||||||
if not settings_path:
|
|
||||||
print("Windows Terminal settings.json not found — skipping.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
with open(settings_path) as f:
|
|
||||||
settings = json.load(f)
|
|
||||||
|
|
||||||
profiles = settings.setdefault("profiles", {}).setdefault("list", [])
|
|
||||||
|
|
||||||
if any(p.get("name") == "Promethean" for p in profiles):
|
|
||||||
print("Promethean profile already exists — skipping.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
profiles.insert(0, {
|
|
||||||
"guid": "{" + str(uuid.uuid4()) + "}",
|
|
||||||
"name": "Promethean",
|
|
||||||
"commandline": f"wsl.exe -d {distro} bash --init-file ~/.promethean_init",
|
|
||||||
"startingDirectory": "~",
|
|
||||||
"icon": icon_path,
|
|
||||||
})
|
|
||||||
|
|
||||||
with open(settings_path, "w") as f:
|
|
||||||
json.dump(settings, f, indent=4)
|
|
||||||
|
|
||||||
print(f"Promethean profile added to Windows Terminal ({settings_path.parent.parent.name}).")
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
# Create desktop shortcut
|
|
||||||
powershell.exe -command "
|
|
||||||
\$ws = New-Object -ComObject WScript.Shell
|
|
||||||
\$s = \$ws.CreateShortcut(\"\$env:USERPROFILE\Desktop\Promethean.lnk\")
|
|
||||||
\$s.TargetPath = 'wt.exe'
|
|
||||||
\$s.Arguments = '--profile Promethean'
|
|
||||||
\$s.IconLocation = 'wt.exe,0'
|
|
||||||
\$s.Save()
|
|
||||||
" 2>/dev/null && echo "Desktop shortcut created." || echo "Could not create desktop shortcut — open Windows Terminal manually."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── 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 ─────────────────────────────────────────────────
|
# ─── Step 1: Sync from router ─────────────────────────────────────────────────
|
||||||
|
|
||||||
echo "Pulling Nexus from router..."
|
echo "Pulling Nexus from router..."
|
||||||
mkdir -p "$NEXUS_ROOT"
|
mkdir -p "$NEXUS_ROOT"
|
||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
|
--exclude='.git/' \
|
||||||
--exclude='Promethean/' \
|
--exclude='Promethean/' \
|
||||||
--exclude='models/blobs/' \
|
--exclude='models/blobs/' \
|
||||||
--exclude='ollama/' \
|
--exclude='ollama/' \
|
||||||
@@ -157,29 +135,33 @@ if ! grep -q "nexus-core/management/nexus-cli.sh" "$HOME/.bashrc"; then
|
|||||||
ncp() {
|
ncp() {
|
||||||
~/nexus-core/management/nexus-cli.sh "$@"
|
~/nexus-core/management/nexus-cli.sh "$@"
|
||||||
}
|
}
|
||||||
alias promethean='source ~/.promethean_bashrc'
|
|
||||||
EOF
|
EOF
|
||||||
echo "ncp registered in ~/.bashrc."
|
echo "ncp registered in ~/.bashrc."
|
||||||
else
|
else
|
||||||
echo "ncp already in ~/.bashrc — skipping."
|
echo "ncp already in ~/.bashrc — skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─── Step 6: Windows Terminal + desktop shortcut ──────────────────────────────
|
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"
|
||||||
if is_wsl; then
|
echo "promethean registered in ~/.bashrc."
|
||||||
echo ""
|
else
|
||||||
echo "Configuring Windows environment..."
|
echo "promethean already in ~/.bashrc — skipping."
|
||||||
setup_windows || true
|
|
||||||
echo ""
|
|
||||||
echo "Reload your shell or open a new terminal for ncp to take effect."
|
|
||||||
fi
|
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 ─────────────────────────────────────────────────────────────────────
|
# ─── Done ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Installation complete. Nexus is ready."
|
echo "Installation complete. Nexus is ready."
|
||||||
if is_wsl; then
|
echo "Run 'ncp start' to launch Nexus."
|
||||||
echo "Open Windows Terminal → Promethean profile, or use the desktop shortcut."
|
|
||||||
else
|
|
||||||
echo "Run 'ncp start' to launch Nexus."
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -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>"
|
||||||
@@ -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."
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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>"
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
export GDK_PROGRAM_CLASS=promethean-terminal
|
|
||||||
exec xfce4-terminal --working-directory ~ -e "bash --rcfile ~/.promethean_bashrc -i" "$@"
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Installs Promethean Terminal: downloads kitty, wires up the config and icon,
|
||||||
|
# and registers the desktop entry in the app menu and on the Desktop.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# This script lives at <repo>/bin/promethean/install.sh; its own dir holds the
|
||||||
|
# kitty conf + desktop entry it installs.
|
||||||
|
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
KITTY_BIN="$HOME/.local/kitty.app/bin/kitty"
|
||||||
|
DESKTOP_FILE="$SELF_DIR/promethean-terminal.desktop"
|
||||||
|
CONF="$SELF_DIR/kitty.conf"
|
||||||
|
NEXUS_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
|
||||||
|
RCFILE="$NEXUS_ROOT/.promethean_bashrc"
|
||||||
|
ICON="$NEXUS_ROOT/assets/promethean-terminal.png"
|
||||||
|
|
||||||
|
# ── 1. Download kitty ────────────────────────────────────────────────────────
|
||||||
|
if [[ -x "$KITTY_BIN" ]]; then
|
||||||
|
echo "kitty already at $KITTY_BIN — skipping download (run with --update to force)"
|
||||||
|
if [[ "${1:-}" == "--update" ]]; then
|
||||||
|
echo "→ Updating kitty..."
|
||||||
|
curl -fsSL https://sw.kovidgoyal.net/kitty/installer.sh | sh /dev/stdin launch=n
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "→ Downloading kitty to ~/.local/kitty.app/ ..."
|
||||||
|
curl -fsSL https://sw.kovidgoyal.net/kitty/installer.sh | sh /dev/stdin launch=n
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " kitty $("$KITTY_BIN" --version)"
|
||||||
|
|
||||||
|
# ── 2. Ensure .promethean_bashrc exists ──────────────────────────────────────
|
||||||
|
if [[ ! -f "$RCFILE" ]]; then
|
||||||
|
echo "→ Creating $RCFILE (sourcing ~/.bashrc) ..."
|
||||||
|
mkdir -p "$(dirname "$RCFILE")"
|
||||||
|
cat > "$RCFILE" <<'EOF'
|
||||||
|
# Promethean Terminal shell init
|
||||||
|
[[ -f ~/.bashrc ]] && source ~/.bashrc
|
||||||
|
export VIRTUAL_ENV_DISABLE_PROMPT=1
|
||||||
|
if [[ -f "$HOME/nexus-core/Promethean/bin/activate" ]]; then
|
||||||
|
source "$HOME/nexus-core/Promethean/bin/activate"
|
||||||
|
fi
|
||||||
|
export PS1='\[\e[0;35m\](Promethean)\[\e[0m\] ${debian_chroot:+($debian_chroot)}\[\e[0;37m\]\u@\h\[\e[0m\]:\[\e[1;32m\]\w\[\e[0m\]\$ '
|
||||||
|
printf '\e]0;Promethean Terminal\a'
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Write the .desktop file (repo copy + installed copies) ────────────────
|
||||||
|
write_desktop() {
|
||||||
|
local dest="$1"
|
||||||
|
cat > "$dest" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Promethean Terminal
|
||||||
|
Comment=Launch a terminal with the Promethean environment
|
||||||
|
Exec=$KITTY_BIN --config $CONF --class promethean-terminal -e bash --rcfile $RCFILE -i
|
||||||
|
Icon=$ICON
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;TerminalEmulator;
|
||||||
|
StartupNotify=false
|
||||||
|
StartupWMClass=promethean-terminal
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "→ Writing desktop entry..."
|
||||||
|
write_desktop "$DESKTOP_FILE"
|
||||||
|
write_desktop "$HOME/.local/share/applications/promethean-terminal.desktop"
|
||||||
|
|
||||||
|
# ── 4. Desktop icon ──────────────────────────────────────────────────────────
|
||||||
|
DESKTOP_DIR="$HOME/Desktop"
|
||||||
|
mkdir -p "$DESKTOP_DIR"
|
||||||
|
cp "$DESKTOP_FILE" "$DESKTOP_DIR/promethean-terminal.desktop"
|
||||||
|
chmod +x "$DESKTOP_DIR/promethean-terminal.desktop"
|
||||||
|
|
||||||
|
# Mark trusted so XFCE/Nemo doesn't show the "untrusted" banner
|
||||||
|
gio set "$DESKTOP_DIR/promethean-terminal.desktop" metadata::trusted true 2>/dev/null \
|
||||||
|
|| xfce4-file-manager --quit 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── 5. Refresh app menu ──────────────────────────────────────────────────────
|
||||||
|
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Promethean Terminal is installed."
|
||||||
|
echo " Binary : $KITTY_BIN"
|
||||||
|
echo " Config : $CONF"
|
||||||
|
echo " Icon : $ICON"
|
||||||
|
echo " Desktop: $DESKTOP_DIR/promethean-terminal.desktop"
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
# Promethean Terminal — Project Nexus
|
# Promethean Terminal — Project Nexus
|
||||||
# Loaded via: kitty --config /home/jon/nexus-core/config/promethean-kitty.conf
|
# Loaded via: kitty --config /home/jon/nexus-core/bin/promethean/kitty.conf
|
||||||
|
|
||||||
# Window title — disable kitty's shell integration title override
|
# Window title — disable kitty's shell integration title override
|
||||||
# so the PS1 escape (\e]0;Promethean Terminal\a) controls the title
|
# so the PS1 escape (\e]0;Promethean Terminal\a) controls the title
|
||||||
shell_integration enabled no-title
|
shell_integration enabled no-title
|
||||||
initial_window_title Promethean Terminal
|
|
||||||
|
# Single-instance mode: subsequent launches reuse the running process (instant open)
|
||||||
|
allow_remote_control yes
|
||||||
|
single_instance yes
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
background #1a0030
|
background #1a0030
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Promethean Terminal
|
||||||
|
Comment=Launch a terminal with the Promethean environment
|
||||||
|
Exec=/home/jon/.local/kitty.app/bin/kitty --config /home/jon/nexus-core/bin/promethean/kitty.conf --class promethean-terminal -e bash --rcfile /home/jon/nexus-core/.promethean_bashrc -i
|
||||||
|
Icon=/home/jon/nexus-core/assets/promethean-terminal.png
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;TerminalEmulator;
|
||||||
|
StartupNotify=false
|
||||||
|
StartupWMClass=promethean-terminal
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
NEXUS_ROOT="$HOME/nexus-core"
|
|
||||||
|
|
||||||
detect_requirements() {
|
|
||||||
if command -v nvidia-smi &>/dev/null || lspci 2>/dev/null | grep -qi nvidia; then
|
|
||||||
echo "nvidia_requirements.txt"
|
|
||||||
else
|
|
||||||
echo "amd_requirements.txt"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Restoring full Nexus backup from router..."
|
|
||||||
lftp sftp://router:2022 << 'LFTP'
|
|
||||||
set sftp:connect-program "ssh -a -x -p 2022"
|
|
||||||
set cmd:interactive yes
|
|
||||||
mirror --delete --verbose --dereference --exclude "^Promethean/" --exclude "^models/blobs/" --exclude "^ollama/" --exclude "^interface/web/node_modules/" --exclude "^interface/web/dist/" --exclude "^runtime/" --exclude "__pycache__" --exclude "\.pyc$" /Wingdrive2/nexus-backup /home/jon/nexus-core
|
|
||||||
quit
|
|
||||||
LFTP
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Rebuilding Python environment..."
|
|
||||||
req=$(detect_requirements)
|
|
||||||
if [ -f "$NEXUS_ROOT/$req" ]; then
|
|
||||||
"$NEXUS_ROOT/Promethean/bin/pip" install -r "$NEXUS_ROOT/$req"
|
|
||||||
else
|
|
||||||
echo "Warning: $req not found — skipping pip install."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Rebuilding frontend dependencies..."
|
|
||||||
cd "$NEXUS_ROOT/interface/web" && npm install
|
|
||||||
|
|
||||||
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 "Restore complete. Nexus is ready to start."
|
|
||||||
@@ -1,27 +1,50 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
NEXUS_ROOT="$HOME/nexus-core"
|
NEXUS_ROOT="$HOME/nexus-core"
|
||||||
|
SRC="router:/tmp/mnt/Wingdrive2/nexus-core/"
|
||||||
|
mode="$1"
|
||||||
|
|
||||||
|
# Shared exclude list — used by the real restore AND the -c/--claude dry-run, so
|
||||||
|
# the check accurately previews what a real restore would do.
|
||||||
|
EXCLUDES=(
|
||||||
|
--exclude='.git/'
|
||||||
|
--exclude='Promethean/'
|
||||||
|
--exclude='models/blobs/'
|
||||||
|
--exclude='ollama/'
|
||||||
|
--exclude='interface/web/node_modules/'
|
||||||
|
--exclude='interface/web/dist/'
|
||||||
|
--exclude='runtime/'
|
||||||
|
--exclude='__pycache__/'
|
||||||
|
--exclude='*.pyc'
|
||||||
|
)
|
||||||
|
|
||||||
detect_requirements() {
|
detect_requirements() {
|
||||||
if command -v nvidia-smi &>/dev/null || lspci 2>/dev/null | grep -qi nvidia; then
|
if command -v nvidia-smi &>/dev/null || lspci 2>/dev/null | grep -qi nvidia; then
|
||||||
echo "nvidia_requirements.txt"
|
echo "requirements-nvidia.txt"
|
||||||
else
|
else
|
||||||
echo "amd_requirements.txt"
|
echo "requirements-amd.txt"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Restoring Nexus from router..."
|
# Check mode (-c|--claude|check): content-level dry-run. Lists exactly what a
|
||||||
rsync -avz --delete \
|
# real restore would pull from the router and delete locally — nothing changes
|
||||||
--exclude='Promethean/' \
|
# and the env/theme rebuild is skipped. --checksum compares by content (not
|
||||||
--exclude='models/blobs/' \
|
# size/mtime), so only genuine differences show. Meant for deciding whether a
|
||||||
--exclude='ollama/' \
|
# real restore is actually needed before paying for the rebuild.
|
||||||
--exclude='interface/web/node_modules/' \
|
if [ "$mode" = "-c" ] || [ "$mode" = "--claude" ] || [ "$mode" = "check" ]; then
|
||||||
--exclude='interface/web/dist/' \
|
echo "Checking restore diff vs router (dry-run, content-level, no changes)..."
|
||||||
--exclude='runtime/' \
|
rsync -rlni --checksum --delete "${EXCLUDES[@]}" -e ssh "$SRC" "$NEXUS_ROOT/"
|
||||||
--exclude='__pycache__/' \
|
echo ""
|
||||||
--exclude='*.pyc' \
|
echo "(dry-run only — nothing changed. Apply with: restore -f)"
|
||||||
-e ssh \
|
exit 0
|
||||||
"router:/tmp/mnt/Wingdrive2/nexus-backup/" "$NEXUS_ROOT/"
|
fi
|
||||||
|
|
||||||
|
if [ "$mode" = "full" ] || [ "$mode" = "-f" ]; then
|
||||||
|
echo "Restoring full Nexus backup from router..."
|
||||||
|
else
|
||||||
|
echo "Restoring Nexus from router..."
|
||||||
|
fi
|
||||||
|
rsync -avz --no-owner --no-group --delete "${EXCLUDES[@]}" -e ssh "$SRC" "$NEXUS_ROOT/"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Rebuilding Python environment..."
|
echo "Rebuilding Python environment..."
|
||||||
@@ -47,5 +70,23 @@ else
|
|||||||
echo "Warning: $theme_installer not found — skipping theme restore."
|
echo "Warning: $theme_installer not found — skipping theme restore."
|
||||||
fi
|
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
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Restore complete. Nexus is ready to start."
|
echo "Restore complete. Nexus is ready to start."
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# NexusOS WSL bootstrap — called by install-windows.ps1 after the repo is copied in.
|
||||||
|
# Safe to re-run; skips steps that are already done.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NEXUS_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$NEXUS_ROOT"
|
||||||
|
|
||||||
|
step() { echo ""; echo "==> $*"; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
warn() { echo " warn: $*"; }
|
||||||
|
|
||||||
|
# ── System packages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step "System packages"
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq \
|
||||||
|
python3 python3-venv python3-pip python3-tk \
|
||||||
|
curl wget git build-essential \
|
||||||
|
libsqlite3-dev rsync
|
||||||
|
ok "System packages ready"
|
||||||
|
|
||||||
|
# ── Node.js (via NodeSource LTS) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
step "Node.js"
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
ok "Already installed: $(node --version)"
|
||||||
|
else
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - 2>/dev/null
|
||||||
|
sudo apt-get install -y -qq nodejs
|
||||||
|
ok "Installed: $(node --version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Python venv ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step "Python venv (Promethean)"
|
||||||
|
if [ ! -d "$NEXUS_ROOT/Promethean" ]; then
|
||||||
|
python3 -m venv "$NEXUS_ROOT/Promethean"
|
||||||
|
ok "Created"
|
||||||
|
else
|
||||||
|
ok "Already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
source "$NEXUS_ROOT/Promethean/bin/activate"
|
||||||
|
pip install --upgrade pip -q
|
||||||
|
|
||||||
|
step "Python dependencies"
|
||||||
|
pip install -r "$NEXUS_ROOT/requirements-wsl.txt" -q
|
||||||
|
ok "Installed"
|
||||||
|
|
||||||
|
# ── Ollama ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step "Ollama"
|
||||||
|
mkdir -p "$NEXUS_ROOT/runtime/logs"
|
||||||
|
OLLAMA_BIN="$NEXUS_ROOT/ollama/bin/ollama"
|
||||||
|
DEFAULT_MODEL="${NEXUS_DEFAULT_MODEL:-qwen2.5:3b}"
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) ;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: bundled Ollama supports x86-64 WSL only (detected $(uname -m))." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -x "$OLLAMA_BIN" ]; then
|
||||||
|
chmod +x "$OLLAMA_BIN" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ ! -x "$OLLAMA_BIN" ] || ! "$OLLAMA_BIN" --version &>/dev/null; then
|
||||||
|
echo "ERROR: bundled Ollama could not run in this WSL distro." >&2
|
||||||
|
echo " Expected a Linux x86-64 executable at: $OLLAMA_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Binary OK: $("$OLLAMA_BIN" --version)"
|
||||||
|
|
||||||
|
# A clean checkout has no model files. Provision one so the first launch can chat.
|
||||||
|
OLLAMA_HOST="127.0.0.1:11434"
|
||||||
|
OLLAMA_MODELS="$NEXUS_ROOT/models"
|
||||||
|
OLLAMA_HOST="$OLLAMA_HOST" OLLAMA_MODELS="$OLLAMA_MODELS" \
|
||||||
|
"$OLLAMA_BIN" list 2>/dev/null | awk -v model="$DEFAULT_MODEL" \
|
||||||
|
'NR > 1 && $1 == model { found = 1 } END { exit !found }' \
|
||||||
|
|| {
|
||||||
|
ollama_pid=""
|
||||||
|
if ! curl -fsS "http://127.0.0.1:11434/api/tags" >/dev/null 2>&1; then
|
||||||
|
OLLAMA_HOST="$OLLAMA_HOST" OLLAMA_MODELS="$NEXUS_ROOT/models" \
|
||||||
|
"$OLLAMA_BIN" serve >>"$NEXUS_ROOT/runtime/logs/ollama.log" 2>&1 &
|
||||||
|
ollama_pid=$!
|
||||||
|
for _ in {1..30}; do
|
||||||
|
curl -fsS "http://127.0.0.1:11434/api/tags" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! curl -fsS "http://127.0.0.1:11434/api/tags" >/dev/null 2>&1; then
|
||||||
|
[ -n "$ollama_pid" ] && kill "$ollama_pid" 2>/dev/null || true
|
||||||
|
echo "ERROR: Ollama did not start; see runtime/logs/ollama.log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! OLLAMA_HOST="$OLLAMA_HOST" OLLAMA_MODELS="$OLLAMA_MODELS" \
|
||||||
|
"$OLLAMA_BIN" list 2>/dev/null | awk -v model="$DEFAULT_MODEL" \
|
||||||
|
'NR > 1 && $1 == model { found = 1 } END { exit !found }'; then
|
||||||
|
echo " pulling default model: $DEFAULT_MODEL"
|
||||||
|
if ! OLLAMA_HOST="$OLLAMA_HOST" OLLAMA_MODELS="$OLLAMA_MODELS" \
|
||||||
|
"$OLLAMA_BIN" pull "$DEFAULT_MODEL"; then
|
||||||
|
[ -n "$ollama_pid" ] && kill "$ollama_pid" 2>/dev/null || true
|
||||||
|
echo "ERROR: Could not pull default model $DEFAULT_MODEL." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -n "$ollama_pid" ] && kill "$ollama_pid" 2>/dev/null || true
|
||||||
|
ok "Model ready: $DEFAULT_MODEL"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Frontend npm deps ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step "Frontend dependencies"
|
||||||
|
cd "$NEXUS_ROOT/interface/web"
|
||||||
|
npm install --silent
|
||||||
|
ok "Installed"
|
||||||
|
cd "$NEXUS_ROOT"
|
||||||
|
|
||||||
|
# ── Runtime dirs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step "Runtime directories"
|
||||||
|
mkdir -p "$NEXUS_ROOT/runtime/pids" "$NEXUS_ROOT/runtime/logs"
|
||||||
|
ok "Ready"
|
||||||
|
|
||||||
|
# ── Shell alias (ncp) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
step "Shell alias"
|
||||||
|
if ! grep -q "nexus-core/management/nexus-cli.sh" "$HOME/.bashrc" 2>/dev/null; then
|
||||||
|
cat >> "$HOME/.bashrc" << 'EOF'
|
||||||
|
|
||||||
|
# NexusOS
|
||||||
|
ncp() { ~/nexus-core/management/nexus-cli.sh "$@"; }
|
||||||
|
EOF
|
||||||
|
ok "ncp alias added to ~/.bashrc"
|
||||||
|
else
|
||||||
|
ok "ncp already in ~/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Promethean command"
|
||||||
|
PROMETHEAN_RC="$NEXUS_ROOT/.promethean_bashrc"
|
||||||
|
if [ ! -f "$PROMETHEAN_RC" ]; then
|
||||||
|
cat > "$PROMETHEAN_RC" << 'EOF'
|
||||||
|
# Promethean Terminal shell init
|
||||||
|
[[ -f ~/.bashrc ]] && source ~/.bashrc
|
||||||
|
export VIRTUAL_ENV_DISABLE_PROMPT=1
|
||||||
|
if [[ -f "$HOME/nexus-core/Promethean/bin/activate" ]]; then
|
||||||
|
source "$HOME/nexus-core/Promethean/bin/activate"
|
||||||
|
fi
|
||||||
|
export PS1='\[\e[0;35m\](Promethean)\[\e[0m\] ${debian_chroot:+($debian_chroot)}\[\e[0;37m\]\u@\h\[\e[0m\]:\[\e[1;32m\]\w\[\e[0m\]\$ '
|
||||||
|
printf '\e]0;Promethean Terminal\a'
|
||||||
|
EOF
|
||||||
|
ok "Promethean environment init created"
|
||||||
|
else
|
||||||
|
ok "Promethean environment init already exists"
|
||||||
|
fi
|
||||||
|
if ! grep -qF "alias promethean='source ~/nexus-core/.promethean_bashrc'" "$HOME/.bashrc" 2>/dev/null; then
|
||||||
|
cat >> "$HOME/.bashrc" << 'EOF'
|
||||||
|
|
||||||
|
# Promethean
|
||||||
|
alias promethean='source ~/nexus-core/.promethean_bashrc'
|
||||||
|
EOF
|
||||||
|
ok "promethean command added to ~/.bashrc"
|
||||||
|
else
|
||||||
|
ok "promethean command already in ~/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Windows Terminal profile + desktop shortcut ───────────────────────────────
|
||||||
|
|
||||||
|
setup_windows() {
|
||||||
|
local win_user distro icon_src icon_win_dir icon_win_path
|
||||||
|
|
||||||
|
win_user=$(powershell.exe -command '$env:USERNAME' 2>/dev/null | tr -d '\r\n')
|
||||||
|
distro="${WSL_DISTRO_NAME:-$(uname -n)}"
|
||||||
|
|
||||||
|
if [ -z "$win_user" ] || [ ! -d "/mnt/c/Users/$win_user" ]; then
|
||||||
|
warn "Cannot locate Windows user directory — skipping Windows Terminal setup"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy app icon into Windows filesystem so WT can display it
|
||||||
|
icon_src="$NEXUS_ROOT/assets/n-small.png"
|
||||||
|
icon_win_dir="/mnt/c/Users/$win_user/AppData/Local/NexusOS"
|
||||||
|
mkdir -p "$icon_win_dir"
|
||||||
|
[ -f "$icon_src" ] && cp "$icon_src" "$icon_win_dir/nexus.png"
|
||||||
|
icon_win_path="C:\\Users\\$win_user\\AppData\\Local\\NexusOS\\nexus.png"
|
||||||
|
|
||||||
|
# Init file so the WT session sources nvm + ncp
|
||||||
|
cat > "$HOME/.nexus_init" << 'EOF'
|
||||||
|
[[ -f ~/.bashrc ]] && source ~/.bashrc
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Patch Windows Terminal settings.json via Python (handles JSONC safely)
|
||||||
|
python3 - "$win_user" "$distro" "$icon_win_path" << 'PYEOF'
|
||||||
|
import json, re, uuid, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
win_user, distro, icon_path = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
f"/mnt/c/Users/{win_user}/AppData/Local/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json",
|
||||||
|
f"/mnt/c/Users/{win_user}/AppData/Local/Packages/Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe/LocalState/settings.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
settings_path = next((Path(p) for p in candidates if Path(p).exists()), None)
|
||||||
|
if not settings_path:
|
||||||
|
print(" warn: Windows Terminal not found — skipping profile")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def strip_jsonc(text):
|
||||||
|
output = []
|
||||||
|
in_string = False
|
||||||
|
escaped = False
|
||||||
|
in_line_comment = False
|
||||||
|
in_block_comment = False
|
||||||
|
index = 0
|
||||||
|
while index < len(text):
|
||||||
|
char = text[index]
|
||||||
|
next_char = text[index + 1] if index + 1 < len(text) else ""
|
||||||
|
if in_line_comment:
|
||||||
|
if char == "\n":
|
||||||
|
in_line_comment = False
|
||||||
|
output.append(char)
|
||||||
|
elif in_block_comment:
|
||||||
|
if char == "*" and next_char == "/":
|
||||||
|
in_block_comment = False
|
||||||
|
index += 1
|
||||||
|
elif in_string:
|
||||||
|
output.append(char)
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == '"':
|
||||||
|
in_string = False
|
||||||
|
elif char == '"':
|
||||||
|
in_string = True
|
||||||
|
output.append(char)
|
||||||
|
elif char == "/" and next_char == "/":
|
||||||
|
in_line_comment = True
|
||||||
|
index += 1
|
||||||
|
elif char == "/" and next_char == "*":
|
||||||
|
in_block_comment = True
|
||||||
|
index += 1
|
||||||
|
else:
|
||||||
|
output.append(char)
|
||||||
|
index += 1
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(settings_path, encoding="utf-8") as f:
|
||||||
|
settings_text = strip_jsonc(f.read())
|
||||||
|
settings = json.loads(re.sub(r",(\s*[}\]])", r"\1", settings_text))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
print(f" warn: Could not read Windows Terminal settings: {error}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
profiles = settings.setdefault("profiles", {}).setdefault("list", [])
|
||||||
|
if any(p.get("name") == "NexusOS" for p in profiles):
|
||||||
|
print(" ok: NexusOS profile already in Windows Terminal")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
profiles.insert(0, {
|
||||||
|
"guid": "{" + str(uuid.uuid4()) + "}",
|
||||||
|
"name": "NexusOS",
|
||||||
|
"commandline": f"wsl.exe -d {distro} bash --init-file ~/.nexus_init",
|
||||||
|
"startingDirectory": "~",
|
||||||
|
"icon": icon_path,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(settings_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(settings, f, indent=4)
|
||||||
|
except OSError as error:
|
||||||
|
print(f" warn: Could not update Windows Terminal settings: {error}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f" ok: NexusOS profile added to Windows Terminal")
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
# Desktop shortcut — opens WT to the NexusOS profile and launches Nexus
|
||||||
|
powershell.exe -command "
|
||||||
|
\$ws = New-Object -ComObject WScript.Shell
|
||||||
|
\$s = \$ws.CreateShortcut(\"\$env:USERPROFILE\Desktop\NexusOS.lnk\")
|
||||||
|
\$s.TargetPath = 'wt.exe'
|
||||||
|
\$s.Arguments = '--profile NexusOS -- bash -c \"cd ~/nexus-core && ./launch_nexus.sh ; exec bash\"'
|
||||||
|
\$s.IconLocation = 'wt.exe,0'
|
||||||
|
\$s.Save()
|
||||||
|
" 2>/dev/null && ok "Desktop shortcut created" || warn "Could not create desktop shortcut"
|
||||||
|
}
|
||||||
|
|
||||||
|
step "Windows integration"
|
||||||
|
if grep -qi microsoft /proc/version 2>/dev/null; then
|
||||||
|
setup_windows
|
||||||
|
else
|
||||||
|
ok "Not running in WSL — skipping Windows Terminal setup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " NexusOS bootstrap complete!"
|
||||||
|
echo ""
|
||||||
|
echo " Launch: cd ~/nexus-core && ./launch_nexus.sh"
|
||||||
|
echo " Frontend: http://localhost:5173"
|
||||||
|
echo " Backend: http://localhost:8000"
|
||||||
|
echo ""
|
||||||
|
echo " Or use the NexusOS shortcut on your Windows desktop."
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
NexusOS installer for Windows.
|
||||||
|
.DESCRIPTION
|
||||||
|
Installs WSL2 + Ubuntu if needed, copies NexusOS into WSL, and runs the
|
||||||
|
bootstrap to set up Python, Node.js, Ollama, and a Windows Terminal profile.
|
||||||
|
|
||||||
|
Run from the nexus-core directory:
|
||||||
|
Right-click install-windows.ps1 -> "Run with PowerShell"
|
||||||
|
|
||||||
|
Requires Windows 10 Build 19041+ or Windows 11.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Distro = "Ubuntu"
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$RepoRoot = $PSScriptRoot
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Write-Step { param([string]$Msg) Write-Host "`n==> $Msg" -ForegroundColor Cyan }
|
||||||
|
function Write-OK { param([string]$Msg) Write-Host " ok: $Msg" -ForegroundColor Green }
|
||||||
|
function Write-Warn { param([string]$Msg) Write-Host " warn: $Msg" -ForegroundColor Yellow }
|
||||||
|
function Write-Fail { param([string]$Msg) Write-Host "`n ERROR: $Msg`n" -ForegroundColor Red; Read-Host "Press Enter to exit"; exit 1 }
|
||||||
|
|
||||||
|
function Pause-And-Exit {
|
||||||
|
param([string]$Msg)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host $Msg -ForegroundColor Yellow
|
||||||
|
Read-Host "Press Enter to exit"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Self-elevate to Administrator ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
$IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||||
|
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
|
||||||
|
if (-not $IsAdmin) {
|
||||||
|
Write-Host "Requesting administrator privileges..." -ForegroundColor Yellow
|
||||||
|
$PsArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -Distro `"$Distro`""
|
||||||
|
Start-Process powershell -ArgumentList $PsArgs -Verb RunAs
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " NexusOS Installer for Windows" -ForegroundColor White
|
||||||
|
Write-Host " ════════════════════════════" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# ── Windows version check ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Step "Checking Windows version"
|
||||||
|
|
||||||
|
$Build = [System.Environment]::OSVersion.Version.Build
|
||||||
|
if ($Build -lt 19041) {
|
||||||
|
Write-Fail "WSL2 requires Windows 10 Build 19041 or later (you have $Build). Please update Windows first."
|
||||||
|
}
|
||||||
|
Write-OK "Windows Build $Build — OK"
|
||||||
|
|
||||||
|
# ── WSL2 check / install ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Step "Checking WSL2"
|
||||||
|
|
||||||
|
$WslExe = Get-Command wsl -ErrorAction SilentlyContinue
|
||||||
|
if (-not $WslExe) {
|
||||||
|
Write-Warn "WSL not found — installing WSL2 + $Distro"
|
||||||
|
wsl --install -d $Distro | Out-Host
|
||||||
|
Pause-And-Exit "WSL has been installed. Please RESTART your computer, then run this installer again."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upgrade any WSL1 distros to WSL2 silently
|
||||||
|
wsl --set-default-version 2 2>&1 | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Fail "Could not set WSL2 as the default version. Enable the WSL2 feature and reboot, then retry."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if our target distro is registered
|
||||||
|
$InstalledDistros = (wsl --list --quiet 2>&1) -split "`n" |
|
||||||
|
ForEach-Object { $_.Trim() -replace '\x00', '' } |
|
||||||
|
Where-Object { $_ -ne "" }
|
||||||
|
|
||||||
|
if ($InstalledDistros -notcontains $Distro) {
|
||||||
|
Write-Warn "$Distro not found — installing"
|
||||||
|
wsl --install -d $Distro | Out-Host
|
||||||
|
Pause-And-Exit "$Distro is being set up. Create a Unix username/password when prompted, then run this installer again."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure the distro runs as WSL2
|
||||||
|
wsl --set-version $Distro 2 2>&1 | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Fail "Could not convert $Distro to WSL2. Check that virtualization is enabled and retry."
|
||||||
|
}
|
||||||
|
wsl --set-default $Distro 2>&1 | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Fail "Could not set $Distro as the default WSL distro."
|
||||||
|
}
|
||||||
|
Write-OK "$Distro is ready (WSL2)"
|
||||||
|
|
||||||
|
# ── Copy repo into WSL ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Step "Copying repo into WSL"
|
||||||
|
|
||||||
|
# Resolve the Windows path to a WSL path
|
||||||
|
$WslSrcRaw = $RepoRoot -replace '\\', '/'
|
||||||
|
$WslSrcPath = (wsl -- wslpath -u "$WslSrcRaw" 2>&1 | Out-String).Trim()
|
||||||
|
if (-not $WslSrcPath) { Write-Fail "Could not convert repo path to WSL path. Is the repo on a Windows drive?" }
|
||||||
|
|
||||||
|
$WslUser = (wsl -- bash -c "echo \$USER" 2>&1 | Out-String).Trim()
|
||||||
|
if (-not $WslUser) { Write-Fail "Could not determine WSL username." }
|
||||||
|
|
||||||
|
$WslDest = "/home/$WslUser/nexus-core"
|
||||||
|
|
||||||
|
Write-Host " Source: $WslSrcPath"
|
||||||
|
Write-Host " Dest: $WslDest"
|
||||||
|
|
||||||
|
# rsync if available, otherwise cp
|
||||||
|
$HasRsync = (wsl -- bash -c "command -v rsync && echo yes || echo no" 2>&1 | Out-String).Trim()
|
||||||
|
if ($HasRsync -eq "yes") {
|
||||||
|
wsl -- bash -c @"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='Promethean/' \
|
||||||
|
--exclude='assets/themes/NexusOS-icons/' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
--exclude='runtime/' \
|
||||||
|
--exclude='__pycache__/' \
|
||||||
|
--exclude='*.pyc' \
|
||||||
|
'$WslSrcPath/' '$WslDest/'
|
||||||
|
"@
|
||||||
|
} else {
|
||||||
|
wsl -- bash -c "mkdir -p '$WslDest' && cp -r '$WslSrcPath/.' '$WslDest/'"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-OK "Repo at $WslDest"
|
||||||
|
|
||||||
|
# ── Run WSL bootstrap ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Step "Running bootstrap inside WSL (this takes a few minutes)"
|
||||||
|
Write-Host " You may be prompted for your WSL sudo password." -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
wsl -- bash -c "chmod +x '$WslDest/bin/wsl-bootstrap.sh' && '$WslDest/bin/wsl-bootstrap.sh'"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Fail "NexusOS bootstrap failed inside WSL. Review the error above and retry the installer."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green
|
||||||
|
Write-Host " NexusOS is installed!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " To launch: open the NexusOS shortcut on your desktop" -ForegroundColor White
|
||||||
|
Write-Host " or open Windows Terminal -> NexusOS profile" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Frontend: http://localhost:5173" -ForegroundColor White
|
||||||
|
Write-Host " Backend: http://localhost:8000" -ForegroundColor White
|
||||||
|
Write-Host " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Read-Host "Press Enter to close"
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
user-desktop.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 976 B |
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
|||||||
folder-drag-accept.png
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1 +0,0 @@
|
|||||||
gtk-network.png
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
|||||||
user-home.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
user-desktop.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
user-home.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder-music.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
network-workgroup.png
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
gtk-network.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
user-desktop.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
user-desktop.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 634 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1 +0,0 @@
|
|||||||
user-bookmarks.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
user-desktop.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1 +0,0 @@
|
|||||||
folder-drag-accept.png
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1 +0,0 @@
|
|||||||
gtk-network.png
|
|
||||||
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1 +0,0 @@
|
|||||||
user-home.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
user-desktop.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
user-home.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder-music.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
network-workgroup.png
|
|
||||||
|
Before Width: | Height: | Size: 6.0 KiB |
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
gtk-network.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
folder.png
|
|
||||||