fix(pine): HA 2026.7.3 satellite keepalive + out-of-box voice seeding + one-click display modes #101
@ -1,5 +1,5 @@
|
|||||||
# Home Assistant - uses official image
|
# Home Assistant - uses official image
|
||||||
FROM homeassistant/home-assistant:2024.1
|
FROM homeassistant/home-assistant:2026.7.3
|
||||||
|
|
||||||
# Default configuration is in the image
|
# Default configuration is in the image
|
||||||
# No additional setup needed
|
# No additional setup needed
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
app:
|
app:
|
||||||
id: homeassistant
|
id: homeassistant
|
||||||
name: Home Assistant
|
name: Home Assistant
|
||||||
version: 2024.1.0
|
version: 2026.7.3
|
||||||
description: Open source home automation platform. Control and monitor your smart home devices.
|
description: Open source home automation platform. Control and monitor your smart home devices.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
|
image: 146.59.87.168:3000/lfg2025/home-assistant:2026.7.3
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: pasta
|
network: pasta
|
||||||
|
|
||||||
|
|||||||
@ -1563,6 +1563,15 @@ autopilot.active=false\n",
|
|||||||
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
||||||
/// Critical hooks (credential setup, config) are awaited; UI container builds are background.
|
/// Critical hooks (credential setup, config) are awaited; UI container builds are background.
|
||||||
async fn run_post_install_hooks(&self, package_id: &str) {
|
async fn run_post_install_hooks(&self, package_id: &str) {
|
||||||
|
if matches!(package_id, "homeassistant" | "home-assistant")
|
||||||
|
&& super::pine_ha::pine_engines_installed().await
|
||||||
|
{
|
||||||
|
// Pine was installed first: wire HA to its Wyoming engines now,
|
||||||
|
// then restart so the seeded storage is what HA loads.
|
||||||
|
if super::pine_ha::seed_home_assistant_pine_defaults().await {
|
||||||
|
super::pine_ha::restart_home_assistant_if_running().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
if package_id == "filebrowser" {
|
if package_id == "filebrowser" {
|
||||||
// Generate a random password (32 bytes, hex-encoded)
|
// Generate a random password (32 bytes, hex-encoded)
|
||||||
let mut buf = [0u8; 32];
|
let mut buf = [0u8; 32];
|
||||||
|
|||||||
@ -3,6 +3,7 @@ mod config;
|
|||||||
mod dependencies;
|
mod dependencies;
|
||||||
mod install;
|
mod install;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
mod pine_ha;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod set_config;
|
mod set_config;
|
||||||
|
|||||||
370
core/archipelago/src/api/rpc/package/pine_ha.rs
Normal file
370
core/archipelago/src/api/rpc/package/pine_ha.rs
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
//! Home Assistant <-> Pine voice-stack provisioning.
|
||||||
|
//!
|
||||||
|
//! The Pine app ships the two Wyoming engines (pine-whisper :10300,
|
||||||
|
//! pine-piper :10200) as plain containers. Home Assistant only uses them once
|
||||||
|
//! a `wyoming` config entry exists for each, and once an Assist pipeline
|
||||||
|
//! points at the resulting `stt.faster_whisper` / `tts.piper` entities. Out of
|
||||||
|
//! the box that meant clicking through HA's integration UI twice and building
|
||||||
|
//! a pipeline by hand — so seed all of it directly into HA's `.storage` when
|
||||||
|
//! both apps are present.
|
||||||
|
//!
|
||||||
|
//! Everything here is best-effort (warn, never fail an install): HA migrates
|
||||||
|
//! the minimal store shapes we write to its current schema on boot, and skips
|
||||||
|
//! entries that already exist, so re-running is idempotent.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
const HA_STORAGE_DIR: &str = "/var/lib/archipelago/home-assistant/.storage";
|
||||||
|
|
||||||
|
/// The two Wyoming engines the Pine stack publishes on the host.
|
||||||
|
/// HA runs under pasta, so the host is reachable as host.containers.internal.
|
||||||
|
const PINE_ENGINES: [(&str, &str, u16); 2] = [
|
||||||
|
("faster-whisper", "host.containers.internal", 10300),
|
||||||
|
("piper", "host.containers.internal", 10200),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Seed Home Assistant with the Pine voice defaults: a `wyoming` config entry
|
||||||
|
/// per engine and an Assist pipeline wired to them. Called after the Pine
|
||||||
|
/// stack installs and after Home Assistant installs (each side no-ops when
|
||||||
|
/// the other is missing). HA picks the new entries up on its next start; the
|
||||||
|
/// caller restarts the container when it is already running.
|
||||||
|
///
|
||||||
|
/// Returns true when anything was written (caller should restart HA).
|
||||||
|
pub(super) async fn seed_home_assistant_pine_defaults() -> bool {
|
||||||
|
let storage = std::path::Path::new(HA_STORAGE_DIR);
|
||||||
|
if let Err(e) = tokio::fs::create_dir_all(storage).await {
|
||||||
|
warn!("pine/HA seed: cannot create {}: {}", HA_STORAGE_DIR, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries_changed = seed_wyoming_config_entries(storage).await;
|
||||||
|
let pipeline_changed = seed_assist_pipeline(storage).await;
|
||||||
|
let intents_changed = seed_voice_intents().await;
|
||||||
|
entries_changed || pipeline_changed || intents_changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker guarding the seeded block in configuration.yaml (idempotence).
|
||||||
|
const VOICE_MARKER: &str = "# --- archipelago pine voice (seeded) ---";
|
||||||
|
|
||||||
|
/// Seed "ask Archy" node-info voice intents: custom sentences + intent_script
|
||||||
|
/// + a REST sensor for the block height (via the mempool backend when that
|
||||||
|
/// app is installed). Skips cleanly when the user already defines
|
||||||
|
/// intent_script themselves.
|
||||||
|
async fn seed_voice_intents() -> bool {
|
||||||
|
let config_dir = std::path::Path::new("/var/lib/archipelago/home-assistant");
|
||||||
|
let config_yaml = config_dir.join("configuration.yaml");
|
||||||
|
|
||||||
|
let existing = tokio::fs::read_to_string(&config_yaml)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
if existing.contains(VOICE_MARKER) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if existing.contains("intent_script:") || existing.contains("\nrest:") {
|
||||||
|
warn!("pine/HA seed: configuration.yaml already defines intent_script/rest — skipping voice intents");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentences file (its own dir, no key-collision risk).
|
||||||
|
let sentences_dir = config_dir.join("custom_sentences/en");
|
||||||
|
if let Err(e) = tokio::fs::create_dir_all(&sentences_dir).await {
|
||||||
|
warn!(
|
||||||
|
"pine/HA seed: cannot create {}: {}",
|
||||||
|
sentences_dir.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sentences = r#"language: "en"
|
||||||
|
intents:
|
||||||
|
ArchyBlockHeight:
|
||||||
|
data:
|
||||||
|
- sentences:
|
||||||
|
- "what's the [current] block height"
|
||||||
|
- "what is the [current] block height"
|
||||||
|
- "[current] block height"
|
||||||
|
- "how many blocks [are there]"
|
||||||
|
"#;
|
||||||
|
if let Err(e) = tokio::fs::write(sentences_dir.join("archy.yaml"), sentences).await {
|
||||||
|
warn!("pine/HA seed: writing custom sentences failed: {e}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mempool_installed = tokio::fs::metadata("/var/lib/archipelago/mempool")
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
|| tokio::fs::metadata("/var/lib/archipelago/data/mempool")
|
||||||
|
.await
|
||||||
|
.is_ok();
|
||||||
|
let mut block = format!("\n{VOICE_MARKER}\n");
|
||||||
|
if mempool_installed {
|
||||||
|
block.push_str(
|
||||||
|
r#"rest:
|
||||||
|
- resource: http://host.containers.internal:8999/api/blocks/tip/height
|
||||||
|
scan_interval: 60
|
||||||
|
sensor:
|
||||||
|
- name: "Archy Block Height"
|
||||||
|
unique_id: archy_block_height
|
||||||
|
value_template: "{{ value }}"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
block.push_str(
|
||||||
|
r#"intent_script:
|
||||||
|
ArchyBlockHeight:
|
||||||
|
speech:
|
||||||
|
text: >-
|
||||||
|
{% if states('sensor.archy_block_height') not in ['unknown', 'unavailable'] %}
|
||||||
|
The block height is {{ states('sensor.archy_block_height') }}.
|
||||||
|
{% else %}
|
||||||
|
I can't read the block height right now.
|
||||||
|
{% endif %}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut merged = existing;
|
||||||
|
merged.push_str(&block);
|
||||||
|
let tmp = config_yaml.with_extension("yaml.tmp-seed");
|
||||||
|
if let Err(e) = tokio::fs::write(&tmp, &merged).await {
|
||||||
|
warn!("pine/HA seed: writing configuration.yaml failed: {e}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Err(e) = tokio::fs::rename(&tmp, &config_yaml).await {
|
||||||
|
warn!("pine/HA seed: replacing configuration.yaml failed: {e}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"pine/HA seed: voice intents seeded (block height{})",
|
||||||
|
if mempool_installed {
|
||||||
|
" + mempool REST sensor"
|
||||||
|
} else {
|
||||||
|
", sensor pending mempool install"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure `core.config_entries` has a `wyoming` entry per Pine engine.
|
||||||
|
async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool {
|
||||||
|
let path = storage.join("core.config_entries");
|
||||||
|
let mut store = if tokio::fs::metadata(&path).await.is_ok() {
|
||||||
|
match read_store(&path).await {
|
||||||
|
Some(v) => v,
|
||||||
|
// Present but unreadable/corrupt: never overwrite it with a
|
||||||
|
// fresh store — that would throw away every integration.
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
json!({
|
||||||
|
"version": 1,
|
||||||
|
"minor_version": 1,
|
||||||
|
"key": "core.config_entries",
|
||||||
|
"data": { "entries": [] }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(entries) = store
|
||||||
|
.get_mut("data")
|
||||||
|
.and_then(|d| d.get_mut("entries"))
|
||||||
|
.and_then(|e| e.as_array_mut())
|
||||||
|
else {
|
||||||
|
warn!("pine/HA seed: core.config_entries has unexpected shape, leaving untouched");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut added = false;
|
||||||
|
for (title, host, port) in PINE_ENGINES {
|
||||||
|
let exists = entries.iter().any(|e| {
|
||||||
|
e.get("domain").and_then(Value::as_str) == Some("wyoming")
|
||||||
|
&& e.get("data")
|
||||||
|
.and_then(|d| d.get("host"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
== Some(host)
|
||||||
|
&& e.get("data")
|
||||||
|
.and_then(|d| d.get("port"))
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
== Some(port as u64)
|
||||||
|
});
|
||||||
|
if exists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry_id: [u8; 16] = rand::random();
|
||||||
|
entries.push(json!({
|
||||||
|
"entry_id": hex::encode(entry_id),
|
||||||
|
"version": 1,
|
||||||
|
"minor_version": 1,
|
||||||
|
"domain": "wyoming",
|
||||||
|
"title": title,
|
||||||
|
"data": { "host": host, "port": port },
|
||||||
|
"options": {},
|
||||||
|
"pref_disable_new_entities": false,
|
||||||
|
"pref_disable_polling": false,
|
||||||
|
"source": "user",
|
||||||
|
"unique_id": null,
|
||||||
|
"disabled_by": null
|
||||||
|
}));
|
||||||
|
info!("pine/HA seed: added wyoming config entry {title} ({host}:{port})");
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if added && !write_store(&path, &store).await {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
added
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure an Assist pipeline uses the Pine engines. Creates the store when
|
||||||
|
/// missing; when it exists, only repairs the pre-2024.6 conversation-agent id
|
||||||
|
/// (`homeassistant` -> `conversation.home_assistant`), which modern HA no
|
||||||
|
/// longer resolves (pipelines error with `intent-not-supported` otherwise).
|
||||||
|
async fn seed_assist_pipeline(storage: &std::path::Path) -> bool {
|
||||||
|
let path = storage.join("assist_pipeline.pipelines");
|
||||||
|
|
||||||
|
if let Some(mut store) = read_store(&path).await {
|
||||||
|
let Some(items) = store
|
||||||
|
.get_mut("data")
|
||||||
|
.and_then(|d| d.get_mut("items"))
|
||||||
|
.and_then(|i| i.as_array_mut())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut repaired = false;
|
||||||
|
for item in items.iter_mut() {
|
||||||
|
if item.get("conversation_engine").and_then(Value::as_str) == Some("homeassistant") {
|
||||||
|
item["conversation_engine"] = json!("conversation.home_assistant");
|
||||||
|
repaired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repaired {
|
||||||
|
info!("pine/HA seed: repaired legacy conversation agent id in Assist pipelines");
|
||||||
|
return write_store(&path, &store).await;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ULID-shaped id (26 chars, Crockford base32) — HA only needs uniqueness.
|
||||||
|
let id: String = {
|
||||||
|
const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||||
|
let raw: [u8; 26] = rand::random();
|
||||||
|
raw.iter()
|
||||||
|
.map(|b| ALPHABET[(*b % 32) as usize] as char)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let store = json!({
|
||||||
|
"version": 1,
|
||||||
|
"minor_version": 1,
|
||||||
|
"key": "assist_pipeline.pipelines",
|
||||||
|
"data": {
|
||||||
|
"items": [{
|
||||||
|
"conversation_engine": "conversation.home_assistant",
|
||||||
|
"conversation_language": "en",
|
||||||
|
"id": id,
|
||||||
|
"language": "en",
|
||||||
|
"name": "Pine (local)",
|
||||||
|
"stt_engine": "stt.faster_whisper",
|
||||||
|
"stt_language": "en",
|
||||||
|
"tts_engine": "tts.piper",
|
||||||
|
"tts_language": "en_GB",
|
||||||
|
"tts_voice": null,
|
||||||
|
"wake_word_entity": null,
|
||||||
|
"wake_word_id": null
|
||||||
|
}],
|
||||||
|
"preferred_item": id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if write_store(&path, &store).await {
|
||||||
|
info!("pine/HA seed: created default Assist pipeline (whisper + piper)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the Pine Wyoming engines are installed on this node (their shared
|
||||||
|
/// data dir is created by the stack install).
|
||||||
|
pub(super) async fn pine_engines_installed() -> bool {
|
||||||
|
tokio::fs::metadata("/var/lib/archipelago/pine")
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when Home Assistant has a config dir on this node (installed at some
|
||||||
|
/// point; .storage may not exist until first boot, which seeding handles).
|
||||||
|
pub(super) async fn home_assistant_installed() -> bool {
|
||||||
|
tokio::fs::metadata("/var/lib/archipelago/home-assistant")
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the HA container so it loads the seeded storage. Best-effort: when
|
||||||
|
/// the container doesn't exist (not yet created) the next start picks the
|
||||||
|
/// seeds up anyway.
|
||||||
|
pub(super) async fn restart_home_assistant_if_running() {
|
||||||
|
let running = tokio::process::Command::new("podman")
|
||||||
|
.args([
|
||||||
|
"ps",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
"--filter",
|
||||||
|
"name=homeassistant",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map(|o| {
|
||||||
|
String::from_utf8_lossy(&o.stdout)
|
||||||
|
.lines()
|
||||||
|
.any(|l| l.trim() == "homeassistant")
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match tokio::process::Command::new("podman")
|
||||||
|
.args(["restart", "homeassistant"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(o) if o.status.success() => info!("pine/HA seed: restarted homeassistant"),
|
||||||
|
Ok(o) => warn!(
|
||||||
|
"pine/HA seed: podman restart homeassistant failed: {}",
|
||||||
|
String::from_utf8_lossy(&o.stderr)
|
||||||
|
),
|
||||||
|
Err(e) => warn!("pine/HA seed: podman restart homeassistant failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_store(path: &std::path::Path) -> Option<Value> {
|
||||||
|
let raw = tokio::fs::read_to_string(path).await.ok()?;
|
||||||
|
match serde_json::from_str(&raw) {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"pine/HA seed: {} is not valid JSON ({}), leaving untouched",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_store(path: &std::path::Path, store: &Value) -> bool {
|
||||||
|
let pretty = match serde_json::to_string_pretty(store) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("pine/HA seed: serialize {} failed: {}", path.display(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Write via temp + rename so HA never reads a half-written store.
|
||||||
|
let tmp = path.with_extension("tmp-seed");
|
||||||
|
if let Err(e) = tokio::fs::write(&tmp, pretty).await {
|
||||||
|
warn!("pine/HA seed: write {} failed: {}", tmp.display(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Err(e) = tokio::fs::rename(&tmp, path).await {
|
||||||
|
warn!("pine/HA seed: rename into {} failed: {}", path.display(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
@ -1934,12 +1934,14 @@ impl RpcHandler {
|
|||||||
if let Some(orchestrated) =
|
if let Some(orchestrated) =
|
||||||
install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await?
|
install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await?
|
||||||
{
|
{
|
||||||
|
Self::seed_pine_ha_defaults().await;
|
||||||
return Ok(orchestrated);
|
return Ok(orchestrated);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(adopted) =
|
if let Some(adopted) =
|
||||||
adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await?
|
adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await?
|
||||||
{
|
{
|
||||||
|
Self::seed_pine_ha_defaults().await;
|
||||||
return Ok(adopted);
|
return Ok(adopted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1947,6 +1949,17 @@ impl RpcHandler {
|
|||||||
"pine manifests not available on this node — the signed catalog must provide apps/pine-*/manifest.yml"
|
"pine manifests not available on this node — the signed catalog must provide apps/pine-*/manifest.yml"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After a Pine install, wire Home Assistant to the new engines when HA is
|
||||||
|
/// on this node (the HA post-install hook covers the reverse order).
|
||||||
|
async fn seed_pine_ha_defaults() {
|
||||||
|
if !super::pine_ha::home_assistant_installed().await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if super::pine_ha::seed_home_assistant_pine_defaults().await {
|
||||||
|
super::pine_ha::restart_home_assistant_if_running().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -546,35 +546,14 @@ onBeforeUnmount(() => {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mode dropdown */
|
/* Active display-mode button in the header bar */
|
||||||
.mode-option {
|
.app-session-btn-active {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.mode-option:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.mode-option-active {
|
|
||||||
color: #fb923c;
|
color: #fb923c;
|
||||||
background: rgba(251, 146, 60, 0.08);
|
background: rgba(251, 146, 60, 0.12);
|
||||||
}
|
}
|
||||||
|
.app-session-btn-active:hover {
|
||||||
.menu-fade-enter-active,
|
color: #fb923c;
|
||||||
.menu-fade-leave-active {
|
background: rgba(251, 146, 60, 0.18);
|
||||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
||||||
}
|
|
||||||
.menu-fade-enter-from,
|
|
||||||
.menu-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-fade-enter-active,
|
.content-fade-enter-active,
|
||||||
|
|||||||
@ -22,63 +22,41 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Display mode selector -->
|
<!-- Display mode: one-click switch -->
|
||||||
<div class="relative" ref="modeMenuRef">
|
<div class="flex items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
class="app-session-btn"
|
class="app-session-btn"
|
||||||
aria-label="Display mode"
|
:class="{ 'app-session-btn-active': displayMode === 'panel' }"
|
||||||
title="Display mode"
|
aria-label="Right panel"
|
||||||
@click="showModeMenu = !showModeMenu"
|
title="Right panel"
|
||||||
|
@click="$emit('setMode', 'panel')"
|
||||||
>
|
>
|
||||||
<!-- Panel icon -->
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg v-if="displayMode === 'panel'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
|
||||||
</svg>
|
</svg>
|
||||||
<!-- Overlay icon -->
|
</button>
|
||||||
<svg v-else-if="displayMode === 'overlay'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button
|
||||||
|
class="app-session-btn"
|
||||||
|
:class="{ 'app-session-btn-active': displayMode === 'overlay' }"
|
||||||
|
aria-label="Over whole app"
|
||||||
|
title="Over whole app"
|
||||||
|
@click="$emit('setMode', 'overlay')"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
|
||||||
</svg>
|
</svg>
|
||||||
<!-- Fullscreen icon -->
|
</button>
|
||||||
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button
|
||||||
|
class="app-session-btn"
|
||||||
|
:class="{ 'app-session-btn-active': displayMode === 'fullscreen' }"
|
||||||
|
aria-label="Open fullscreen"
|
||||||
|
title="Open fullscreen"
|
||||||
|
@click="$emit('setMode', 'fullscreen')"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Dropdown -->
|
|
||||||
<Transition name="menu-fade">
|
|
||||||
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 w-48 bg-black/90 border border-white/10 rounded-lg backdrop-blur-xl shadow-2xl overflow-hidden z-50">
|
|
||||||
<button
|
|
||||||
class="mode-option"
|
|
||||||
:class="{ 'mode-option-active': displayMode === 'panel' }"
|
|
||||||
@click="selectMode('panel')"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
|
|
||||||
</svg>
|
|
||||||
<span>Right panel</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="mode-option"
|
|
||||||
:class="{ 'mode-option-active': displayMode === 'overlay' }"
|
|
||||||
@click="selectMode('overlay')"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
|
|
||||||
</svg>
|
|
||||||
<span>Over whole app</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="mode-option"
|
|
||||||
:class="{ 'mode-option-active': displayMode === 'fullscreen' }"
|
|
||||||
@click="selectMode('fullscreen')"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
|
||||||
</svg>
|
|
||||||
<span>Open fullscreen</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="app-session-btn" aria-label="Open in new tab" title="Open in new tab" @click="$emit('openNewTab')">
|
<button class="app-session-btn" aria-label="Open in new tab" title="Open in new tab" @click="$emit('openNewTab')">
|
||||||
@ -98,7 +76,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
import type { DisplayMode } from './appSessionConfig'
|
import type { DisplayMode } from './appSessionConfig'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@ -107,7 +84,7 @@ defineProps<{
|
|||||||
displayMode: DisplayMode
|
displayMode: DisplayMode
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
defineEmits<{
|
||||||
goBack: []
|
goBack: []
|
||||||
goForward: []
|
goForward: []
|
||||||
refresh: []
|
refresh: []
|
||||||
@ -115,26 +92,4 @@ const emit = defineEmits<{
|
|||||||
close: []
|
close: []
|
||||||
setMode: [mode: DisplayMode]
|
setMode: [mode: DisplayMode]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const showModeMenu = ref(false)
|
|
||||||
const modeMenuRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
function selectMode(mode: DisplayMode) {
|
|
||||||
showModeMenu.value = false
|
|
||||||
emit('setMode', mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClickOutside(e: MouseEvent) {
|
|
||||||
if (showModeMenu.value && modeMenuRef.value && !modeMenuRef.value.contains(e.target as Node)) {
|
|
||||||
showModeMenu.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', onClickOutside)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('click', onClickOutside)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -31,7 +31,7 @@ POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
|||||||
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
|
||||||
|
|
||||||
# Apps
|
# Apps
|
||||||
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2024.1"
|
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2026.7.3"
|
||||||
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
|
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
|
||||||
UPTIME_KUMA_IMAGE="$ARCHY_REGISTRY/uptime-kuma:1"
|
UPTIME_KUMA_IMAGE="$ARCHY_REGISTRY/uptime-kuma:1"
|
||||||
JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.8.13"
|
JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.8.13"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user