Galaxy Pair Locally

This commit is contained in:
firestarsdog
2026-03-03 22:28:27 -05:00
parent 876a45122f
commit c84acea993
6 changed files with 346 additions and 0 deletions
@@ -4,6 +4,7 @@ import { hideSidebar } from "/assets/js/utils.js"
import { DeviceSettings } from "/assets/components/tools/device_settings.js"
import { DoorControl } from "/assets/components/tools/doors.js"
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
import { Home } from "/assets/components/home/home.js"
import { NavDestination } from "/assets/components/navigation/navigation_destination.js"
import { NavKeys } from "/assets/components/navigation/navigation_keys.js"
@@ -34,6 +35,7 @@ function Root() {
createRoute("device_settings", "/device_settings", DeviceSettings),
createRoute("doors", "/lock_or_unlock_doors", DoorControl),
createRoute("errorLogs", "/manage_error_logs", ErrorLogs),
createRoute("galaxy", "/galaxy", GalaxyPairing),
createRoute("navdestination", "/set_navigation_destination", NavDestination),
createRoute("navkeys", "/manage_navigation_keys", NavKeys),
createRoute("root", "/", Home),
@@ -18,6 +18,7 @@ const MenuItems = {
{ name: "Device Settings", link: "/device_settings", icon: "bi-sliders" },
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Lock/Unlock Doors", link: "/lock_or_unlock_doors", icon: "bi-door-closed" },
{ name: "Model Manager", link: "/manage_models", icon: "bi-cpu" },
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
@@ -0,0 +1,137 @@
/* ――― Galaxy Pairing Widget ――― */
.galaxy-wrapper {
max-width: var(--width-xxxl);
padding: var(--padding-base) var(--padding-lg) var(--padding-xxl);
}
.galaxy-loading {
color: var(--text-muted);
padding: var(--padding-xl);
text-align: center;
}
.galaxy-widget {
align-items: center;
background-color: var(--secondary-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--gap-lg);
max-width: var(--width-lg);
padding: var(--padding-xl);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.galaxy-widget:hover {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
/* ――― Status Badge ――― */
.galaxy-status-badge {
align-items: center;
border-radius: 2rem;
display: inline-flex;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
gap: var(--gap-xs);
padding: 0.3rem 1rem;
}
.galaxy-paired {
background-color: rgba(94, 200, 200, 0.15);
color: var(--success-fg);
}
.galaxy-unpaired {
background-color: rgba(224, 85, 119, 0.15);
color: var(--danger-fg);
}
/* ――― Text ――― */
.galaxy-text {
color: var(--text-color);
line-height: var(--line-height-base);
text-align: center;
}
/* ――― URL Link ――― */
.galaxy-url {
color: var(--main-fg);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
text-decoration: underline;
text-underline-offset: 3px;
transition: color var(--transition-fast);
word-break: break-all;
}
.galaxy-url:hover {
color: var(--accent-hover-bg);
}
/* ――― Input Group ――― */
.galaxy-input-group {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
width: 100%;
}
.galaxy-input {
background-color: var(--input-bg);
border: var(--border-style-input);
border-radius: var(--border-radius-lg);
box-sizing: border-box;
color: var(--text-color);
font-family: var(--font-body);
font-size: var(--font-size-base);
outline: none;
padding: var(--padding-sm) var(--padding-base);
transition: box-shadow var(--transition-fast);
width: 100%;
}
.galaxy-input:focus {
box-shadow: 0 0 0 2px var(--main-fg);
}
/* ――― Buttons ――― */
.galaxy-button {
background-color: var(--input-bg);
border: none;
border-radius: var(--border-radius-lg);
color: var(--text-color);
font-size: var(--font-size-base);
font-weight: var(--font-weight-demi-bold);
padding: var(--padding-sm) var(--padding-base);
text-align: center;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
width: 100%;
}
.galaxy-button:hover:not(:disabled) {
box-shadow: var(--shadow-md);
transform: var(--hover-scale-sm);
}
.galaxy-button:disabled {
opacity: var(--disabled-opacity);
}
.galaxy-button-danger {
background-color: rgba(224, 85, 119, 0.2);
color: var(--danger-fg);
}
.galaxy-button-danger:hover:not(:disabled) {
background-color: rgba(224, 85, 119, 0.35);
}
/* ――― Mobile ――― */
@media only screen and (max-width: 768px) and (orientation: portrait) {
.galaxy-wrapper {
padding: var(--padding-sm) var(--padding-base) var(--padding-xl);
}
}
@@ -0,0 +1,166 @@
import { html, reactive } from "https://esm.sh/@arrow-js/core"
import { isGalaxyTunnel } from "/assets/js/utils.js"
import { Modal } from "/assets/components/modal.js"
const state = reactive({
paired: false,
url: "",
password: "",
loading: true,
submitting: false,
showUnpairModal: false,
fetched: false,
})
async function fetchStatus() {
state.loading = true
try {
const res = await fetch("/api/galaxy/status")
const data = await res.json()
state.paired = data.paired
state.url = data.url
} catch (e) {
console.error("Failed to fetch Galaxy status:", e)
}
state.loading = false
}
async function pair() {
if (state.submitting) return
const pw = state.password.trim()
if (pw.length < 6) {
showSnackbar("Password must be at least 6 characters.")
return
}
state.submitting = true
try {
const res = await fetch("/api/galaxy/pair", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw }),
})
const data = await res.json()
if (res.ok) {
state.paired = true
state.url = data.url
state.password = ""
// Clear the DOM input value directly (Arrow.js doesn't two-way bind)
const input = document.querySelector(".galaxy-input")
if (input) input.value = ""
showSnackbar(data.message || "Paired!")
} else {
showSnackbar(data.error || "Pairing failed.")
}
} catch (e) {
showSnackbar("Network error — is the device reachable?")
}
state.submitting = false
}
async function unpair() {
state.showUnpairModal = false
state.submitting = true
try {
const res = await fetch("/api/galaxy/unpair", { method: "POST" })
const data = await res.json()
if (res.ok) {
state.paired = false
state.url = ""
showSnackbar(data.message || "Unpaired!")
} else {
showSnackbar(data.error || "Unpairing failed.")
}
} catch (e) {
showSnackbar("Network error — is the device reachable?")
}
state.submitting = false
}
export function GalaxyPairing() {
if (isGalaxyTunnel()) {
return html`
<div class="tunnel-notice">
<div class="tunnel-notice-icon">🛰️</div>
<h3 class="tunnel-notice-title">Galaxy Pairing Unavailable via Galaxy</h3>
<p class="tunnel-notice-body">Galaxy pairing requires a direct connection.<br>Connect to your device's local network to use this feature.</p>
</div>
`;
}
if (!state.fetched) {
state.fetched = true
fetchStatus()
}
return html`
<div class="galaxy-wrapper">
<h2>Galaxy</h2>
${() => {
if (state.loading) {
return html`<div class="galaxy-loading">Checking pairing status…</div>`
}
if (state.paired) {
return html`
<section class="galaxy-widget">
<div class="galaxy-status-badge galaxy-paired">
<i class="bi bi-check-circle-fill"></i> Paired
</div>
<p class="galaxy-text">
Your device is paired with Galaxy. Access it remotely at:
</p>
<a class="galaxy-url" href="${state.url}" target="_blank" rel="noopener">
${state.url}
</a>
<button
class="galaxy-button galaxy-button-danger"
@click="${() => { state.showUnpairModal = true }}"
disabled="${() => state.submitting}"
>
${() => state.submitting ? "Unpairing…" : "Unpair"}
</button>
</section>
${() => state.showUnpairModal ? Modal({
title: "Confirm Unpair",
message: "Are you sure you want to unpair from Galaxy? You will lose remote access until you pair again.",
onConfirm: unpair,
onCancel: () => { state.showUnpairModal = false },
confirmText: "Unpair",
}) : ""}
`
}
return html`
<section class="galaxy-widget">
<div class="galaxy-status-badge galaxy-unpaired">
<i class="bi bi-x-circle-fill"></i> Not Paired
</div>
<p class="galaxy-text">
Pair your device with Galaxy to access The Pond remotely from anywhere.
Enter a password to secure your connection.
</p>
<div class="galaxy-input-group">
<input
class="galaxy-input"
type="password"
placeholder="Password (min 6 characters)"
@input="${(e) => { state.password = e.target.value }}"
@keydown="${(e) => { if (e.key === 'Enter') pair() }}"
/>
<button
class="galaxy-button"
@click="${pair}"
disabled="${() => state.submitting || state.password.trim().length < 6}"
>
${() => state.submitting ? "Pairing…" : "Pair"}
</button>
</div>
</section>
`
}}
</div>
`
}
@@ -34,6 +34,7 @@
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
<link rel="stylesheet" href="/assets/components/tools/device_settings.css">
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script type="module" src="/assets/components/router.js"></script>
+39
View File
@@ -1153,6 +1153,45 @@ def setup(app):
},
}
# ── Galaxy pairing (mirrors settings.cc L262-282) ──────────────────
GALAXY_DIR = Path("/data/galaxy")
GALAXY_AUTH_FILE = GALAXY_DIR / "glxyauth"
@app.route("/api/galaxy/status", methods=["GET"])
def galaxy_status():
try:
paired = GALAXY_AUTH_FILE.is_file() and len(GALAXY_AUTH_FILE.read_text().strip()) == 64
except Exception:
paired = False
dongle_id = params.get("DongleId", encoding="utf8") or ""
return jsonify({
"paired": paired,
"url": f"https://galaxy.firestar.link/{dongle_id}" if dongle_id else "",
})
@app.route("/api/galaxy/pair", methods=["POST"])
def galaxy_pair():
data = request.get_json() or {}
password = (data.get("password") or "").strip()
if len(password) < 6:
return jsonify({"error": "Password must be at least 6 characters."}), 400
pw_hash = hashlib.sha256(password.encode()).hexdigest()
GALAXY_DIR.mkdir(parents=True, exist_ok=True)
GALAXY_AUTH_FILE.write_text(pw_hash)
dongle_id = params.get("DongleId", encoding="utf8") or ""
return jsonify({
"message": "Pairing successful!",
"url": f"https://galaxy.firestar.link/{dongle_id}" if dongle_id else "",
})
@app.route("/api/galaxy/unpair", methods=["POST"])
def galaxy_unpair():
if GALAXY_AUTH_FILE.is_file():
GALAXY_AUTH_FILE.unlink()
return jsonify({"message": "Galaxy unpaired successfully."})
@app.route("/api/tailscale/installed", methods=["GET"])
def tailscale_installed():
base = "/data/tailscale"