Merge upstream main and resolve conflicts in nix-monitor

This commit is contained in:
avivbintangaringga
2026-07-18 14:50:51 +07:00
81 changed files with 7415 additions and 456 deletions
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env -S bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
API_BASE="${I18N_API_BASE:-https://i18n.noctalia.dev}"
PROJECT_SLUG="community-plugins"
if [[ $# -ne 0 ]]; then
echo "Usage: $0" >&2
exit 1
fi
for command in curl jq; do
if ! command -v "$command" >/dev/null 2>&1; then
echo "Error: $command is required" >&2
exit 1
fi
done
echo "Project: $PROJECT_SLUG"
echo "Output repository: $REPO_ROOT"
read -r -p "Pull translations and overwrite returned local files? [y/N] " reply
if [[ ! "$reply" =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
RESPONSE_FILE="$(mktemp)"
STAGING_DIR="$(mktemp -d)"
trap 'rm -f "$RESPONSE_FILE"; rm -rf "$STAGING_DIR"' EXIT
HTTP_CODE="$(curl --silent --show-error \
--output "$RESPONSE_FILE" \
--write-out '%{http_code}' \
"$API_BASE/api/projects/$PROJECT_SLUG/pull")"
if [[ "$HTTP_CODE" != "200" ]]; then
echo "Error: HTTP $HTTP_CODE" >&2
jq . "$RESPONSE_FILE" 2>/dev/null || cat "$RESPONSE_FILE"
exit 1
fi
if ! jq -e 'type == "object" and length > 0' "$RESPONSE_FILE" >/dev/null; then
echo "Error: API response must be a non-empty locale object" >&2
exit 1
fi
mapfile -t LOCALES < <(jq -r 'keys[]' "$RESPONSE_FILE")
FILE_COUNT=0
for locale in "${LOCALES[@]}"; do
if [[ ! "$locale" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
echo "Error: API returned an invalid locale: $locale" >&2
exit 1
fi
if ! jq -e --arg locale "$locale" '.[$locale] | type == "object" and length > 0' "$RESPONSE_FILE" >/dev/null; then
echo "Error: Locale payload must be a non-empty object: $locale" >&2
exit 1
fi
mapfile -t PLUGINS < <(jq -r --arg locale "$locale" '.[$locale] | keys[]' "$RESPONSE_FILE")
for plugin in "${PLUGINS[@]}"; do
if [[ ! "$plugin" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
echo "Error: API returned an invalid plugin name: $plugin" >&2
exit 1
fi
if [[ ! -f "$REPO_ROOT/$plugin/plugin.toml" ]]; then
echo "Error: API returned unknown plugin: $plugin" >&2
exit 1
fi
if ! jq -e --arg locale "$locale" --arg plugin "$plugin" '
def valid_translation:
if type == "object" then all(.[]; valid_translation)
else type == "string"
end;
.[$locale][$plugin] | type == "object" and valid_translation
' "$RESPONSE_FILE" >/dev/null; then
echo "Error: Invalid translation payload for $plugin/$locale" >&2
exit 1
fi
mkdir -p "$STAGING_DIR/$plugin/translations"
jq --arg locale "$locale" --arg plugin "$plugin" \
'.[$locale][$plugin]' "$RESPONSE_FILE" \
>"$STAGING_DIR/$plugin/translations/$locale.json"
FILE_COUNT=$((FILE_COUNT + 1))
done
done
shopt -s nullglob
MANIFESTS=("$REPO_ROOT"/*/plugin.toml)
for manifest in "${MANIFESTS[@]}"; do
plugin="$(basename -- "$(dirname -- "$manifest")")"
if ! jq -e --arg plugin "$plugin" '.en[$plugin] | type == "object"' "$RESPONSE_FILE" >/dev/null; then
echo "Error: English payload is missing plugin: $plugin" >&2
exit 1
fi
done
while IFS= read -r -d '' staged_file; do
relative_path="${staged_file#"$STAGING_DIR/"}"
output_file="$REPO_ROOT/$relative_path"
output_dir="$(dirname -- "$output_file")"
mkdir -p "$output_dir"
temporary_file="$(mktemp "$output_dir/.i18n-pull.XXXXXX")"
cp -- "$staged_file" "$temporary_file"
mv -- "$temporary_file" "$output_file"
echo "Saved: $relative_path"
done < <(find "$STAGING_DIR" -type f -name '*.json' -print0 | sort -z)
echo "Successfully pulled $FILE_COUNT translation file(s)."
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env -S bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
API_BASE="${I18N_API_BASE:-https://i18n.noctalia.dev}"
PROJECT_SLUG="community-plugins"
OVERWRITE=false
SINGLE_LANG=""
usage() {
echo "Usage: COMMUNITY_PLUGINS_PUSH_SECRET=... $0 [--overwrite] [--lang <locale>]"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--overwrite)
OVERWRITE=true
shift
;;
--lang)
if [[ $# -lt 2 ]]; then
echo "Error: --lang requires a locale" >&2
usage >&2
exit 1
fi
SINGLE_LANG="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "Error: Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
for command in curl jq; do
if ! command -v "$command" >/dev/null 2>&1; then
echo "Error: $command is required" >&2
exit 1
fi
done
if [[ -z "${COMMUNITY_PLUGINS_PUSH_SECRET:-}" ]]; then
echo "Error: COMMUNITY_PLUGINS_PUSH_SECRET is required" >&2
exit 1
fi
if [[ -n "$SINGLE_LANG" && ! "$SINGLE_LANG" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
echo "Error: Invalid locale: $SINGLE_LANG" >&2
exit 1
fi
shopt -s nullglob
MANIFESTS=("$REPO_ROOT"/*/plugin.toml)
if [[ ${#MANIFESTS[@]} -eq 0 ]]; then
echo "Error: No plugin manifests found in $REPO_ROOT" >&2
exit 1
fi
validate_translation_file() {
local file="$1"
if ! jq -e '
def valid_translation:
if type == "object" then all(.[]; valid_translation)
else type == "string"
end;
type == "object" and valid_translation
' "$file" >/dev/null; then
echo "Error: Translation must be a JSON object containing only objects and strings: $file" >&2
exit 1
fi
}
COMBINED_JSON='{}'
FILE_COUNT=0
for manifest in "${MANIFESTS[@]}"; do
plugin_dir="$(dirname -- "$manifest")"
plugin="$(basename -- "$plugin_dir")"
if [[ ! "$plugin" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
echo "Error: Invalid plugin directory name: $plugin" >&2
exit 1
fi
if [[ -n "$SINGLE_LANG" ]]; then
translation_files=("$plugin_dir/translations/$SINGLE_LANG.json")
else
english_file="$plugin_dir/translations/en.json"
if [[ ! -f "$english_file" ]]; then
echo "Error: Missing English translation: $english_file" >&2
exit 1
fi
translation_files=("$plugin_dir"/translations/*.json)
fi
for file in "${translation_files[@]}"; do
[[ -f "$file" ]] || continue
locale="$(basename -- "$file" .json)"
if [[ ! "$locale" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
echo "Error: Invalid locale filename: $file" >&2
exit 1
fi
validate_translation_file "$file"
COMBINED_JSON="$(jq \
--arg locale "$locale" \
--arg plugin "$plugin" \
--slurpfile content "$file" \
'. + {($locale): ((.[$locale] // {}) + {($plugin): $content[0]})}' \
<<<"$COMBINED_JSON")"
echo "Loaded: $plugin/translations/$locale.json"
FILE_COUNT=$((FILE_COUNT + 1))
done
done
if [[ $FILE_COUNT -eq 0 ]]; then
if [[ -n "$SINGLE_LANG" ]]; then
echo "Error: No plugin provides locale: $SINGLE_LANG" >&2
else
echo "Error: No translation files found" >&2
fi
exit 1
fi
LOCALE_COUNT="$(jq 'keys | length' <<<"$COMBINED_JSON")"
echo "Project: $PROJECT_SLUG"
echo "Found $FILE_COUNT file(s) across $LOCALE_COUNT locale(s)"
read -r -p "Push these translations to $API_BASE? [y/N] " reply
if [[ ! "$reply" =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
PUSH_URL="$API_BASE/api/projects/$PROJECT_SLUG/push"
if [[ "$OVERWRITE" == true ]]; then
PUSH_URL="$PUSH_URL?overwrite=true"
echo "Overwrite mode enabled"
fi
RESPONSE_FILE="$(mktemp)"
trap 'rm -f "$RESPONSE_FILE"' EXIT
HTTP_CODE="$(curl --silent --show-error \
--output "$RESPONSE_FILE" \
--write-out '%{http_code}' \
--request POST \
--header "Authorization: Bearer $COMMUNITY_PLUGINS_PUSH_SECRET" \
--header "Content-Type: application/json" \
--data-binary @- \
"$PUSH_URL" <<<"$COMBINED_JSON")"
if [[ "$HTTP_CODE" != "200" ]]; then
echo "Error: HTTP $HTTP_CODE" >&2
jq . "$RESPONSE_FILE" 2>/dev/null || cat "$RESPONSE_FILE"
exit 1
fi
echo "Translations pushed successfully."
jq . "$RESPONSE_FILE" 2>/dev/null || cat "$RESPONSE_FILE"
+9
View File
@@ -126,6 +126,15 @@ error messages show the exact missing value, while maintainers review the useful
Write `translations/en.json` only. Every `label_key` and `description_key` in your manifest must resolve to a key in Write `translations/en.json` only. Every `label_key` and `description_key` in your manifest must resolve to a key in
it, and CI checks this. Do not add machine-translated locales; other languages are handled separately. it, and CI checks this. Do not add machine-translated locales; other languages are handled separately.
To test the latest translated locales from [Noctalia Translate](https://i18n.noctalia.dev) in a working checkout, run:
```sh
./.tools/i18n-pull.sh
```
The command asks for confirmation and overwrites the locale files returned by the translation service. It does not
delete local locale files that are absent from the export. Review the resulting diff before committing anything.
### Tags ### Tags
The `tags` in `plugin.toml` are used for catalog search. Tags must be lowercase and selected from this list: The `tags` in `plugin.toml` are used for catalog search. Tags must be lowercase and selected from this list:
+71 -5
View File
@@ -65,7 +65,7 @@ tags = ["launcher"]
[[plugin]] [[plugin]]
id = "gambled23/mangowm-keymode" id = "gambled23/mangowm-keymode"
name = "Mangowm Keymode" name = "Mangowm Keymode"
version = "1.0.1" version = "1.0.2"
author = "gambled23" author = "gambled23"
license = "MIT" license = "MIT"
icon = "keyboard" icon = "keyboard"
@@ -77,7 +77,7 @@ tags = ["bar", "mangowc"]
[[plugin]] [[plugin]]
id = "oldirtty/color_picker" id = "oldirtty/color_picker"
name = "Color Picker" name = "Color Picker"
version = "1.0.2" version = "1.0.3"
author = "oldirtty" author = "oldirtty"
license = "MIT" license = "MIT"
icon = "palette" icon = "palette"
@@ -99,11 +99,11 @@ tags = ["hyprland", "sway", "desktop", "utility"]
[[plugin]] [[plugin]]
id = "radimous/prismlauncher-instances" id = "radimous/prismlauncher-instances"
name = "PrismLauncher Instances" name = "PrismLauncher Instances"
version = "1.0.0" version = "1.0.1"
author = "radimous" author = "radimous"
license = "MIT" license = "MIT"
icon = "nut" icon = "nut"
description = "A launcher provider that adds PrismLauncher instances to the noctalia launcher." description = "A launcher provider that adds PrismLauncher and PrismLauncher fork instances to the noctalia launcher."
deprecated = false deprecated = false
plugin_api = 3 plugin_api = 3
tags = ["gaming", "launcher"] tags = ["gaming", "launcher"]
@@ -155,7 +155,7 @@ tags = ["hardware"]
[[plugin]] [[plugin]]
id = "rxtsel/portctl" id = "rxtsel/portctl"
name = "Portctl" name = "Portctl"
version = "0.2.0" version = "0.2.1"
author = "Cristhian Melo" author = "Cristhian Melo"
license = "MIT" license = "MIT"
icon = "plug" icon = "plug"
@@ -173,3 +173,69 @@ icon = "device-laptop"
description = "Toggles battery conservation mode via the ideapad_acpi driver, on Lenovo IdeaPad and Legion laptops." description = "Toggles battery conservation mode via the ideapad_acpi driver, on Lenovo IdeaPad and Legion laptops."
plugin_api = 3 plugin_api = 3
tags = ["hardware", "shortcut", "system"] tags = ["hardware", "shortcut", "system"]
[[plugin]]
id = "goodroot/noctwhspr"
name = "noctwhspr"
version = "1.0.0"
author = "goodroot"
license = "MIT"
icon = "microphone"
description = "Noctalia companion for hyprwhspr: shows dictation state, click to record, right-click to restart."
plugin_api = 3
tags = ["audio", "bar", "recording", "utility"]
[[plugin]]
id = "nightwatch75/file-search"
name = "File Search"
version = "0.0.10"
author = "nightwatch75"
license = "MIT"
icon = "search"
description = "Search files and folders as you type, fuzzy-matched with fzf; open results with the system MIME association."
plugin_api = 3
tags = ["bar", "launcher", "panel", "utility", "productivity"]
[[plugin]]
id = "h465855hgg/lyrics"
name = "Lyrics"
version = "1.3.0"
author = "h465855hgg"
license = "MIT"
icon = "music"
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
plugin_api = 3
tags = ["bar", "service", "music", "media", "animation"]
[[plugin]]
id = "whyoolw/dropwall"
name = "DropWall"
version = "1.0.0"
author = "whyoolw"
license = "MIT"
icon = "image"
description = "Drag and drop an image onto the desktop to set it as wallpaper using Noctalia's wallpaper settings."
plugin_api = 3
tags = ["desktop", "wallpaper"]
[[plugin]]
id = "cleboost/zed-provider"
name = "Zed Provider"
version = "1.0.0"
author = "cleboost"
license = "MIT"
icon = "folder-open"
description = "Open a recent Zed project from the launcher. Type /zed to list your projects."
plugin_api = 3
tags = ["launcher", "development", "productivity"]
[[plugin]]
id = "pozzoo/hassio"
name = "Home Assistant"
version = "2.0.0"
author = "Pozzoo"
license = "MIT"
icon = "smart-home"
description = "Monitor and control Home Assistant entities from the bar. Displays entity states and provides quick toggles."
plugin_api = 4
tags = ["bar", "panel", "service", "shortcut", "network", "indicator"]
+1 -1
View File
@@ -3,7 +3,7 @@ id = "oldirtty/color_picker"
name = "Color Picker" name = "Color Picker"
description = "Pick a color from your screen with hyprpicker." description = "Pick a color from your screen with hyprpicker."
license = "MIT" license = "MIT"
version = "1.0.2" version = "1.0.3"
plugin_api = 3 plugin_api = 3
icon = "palette" icon = "palette"
dependencies = ["hyprpicker"] dependencies = ["hyprpicker"]
+23 -25
View File
@@ -9,9 +9,9 @@
-- CONSTANTS -- CONSTANTS
--============================================== --==============================================
local MAX_HISTORY = 6 local MAX_HISTORY = 6
local STATE_DIR = "~/.local/state/noctalia/plugin-cache/community/color_picker" local PERSISTENT_DIR = noctalia.pluginDataDir()
local HISTORY_FILE = STATE_DIR .. "/history.json" local HISTORY_FILE = PERSISTENT_DIR .. "/history.json"
local SELECTED_FILE = STATE_DIR .. "/selected.json" local SELECTED_FILE = PERSISTENT_DIR .. "/selected.json"
local tr_color_picker = noctalia.tr("color_picker") local tr_color_picker = noctalia.tr("color_picker")
--============================================== --==============================================
@@ -101,11 +101,9 @@ end
--============================================== --==============================================
-- STATE MODEL -- STATE MODEL
-- --
-- selectedColor / selectedOpacity: ephemeral, what the panel is currently -- selectedColor / selectedOpacity: persisted color, state is saved once
-- showing. Set here on every successful pick; also updated by the panel -- per panel session when closed.
-- itself for pure-UI interactions (history swatch click, field edit,
-- opacity slider) that don't touch hyprpicker or disk.
--
-- colorHistory: persisted list. -- colorHistory: persisted list.
-- Only mutated by pushCurrentColor(), called on immediate-commit picks -- Only mutated by pushCurrentColor(), called on immediate-commit picks
-- (widget right-click) and on "commit" (panel close). -- (widget right-click) and on "commit" (panel close).
@@ -139,37 +137,37 @@ local function pushCurrentColor(hex: string, opacity: number)
end end
local function loadHistoryFromDisk() local function loadHistoryFromDisk()
local content = noctalia.readFile(HISTORY_FILE) local raw = noctalia.readFile(HISTORY_FILE)
if content == nil or content == "" then return end if raw == nil or raw == "" then return end
local decoded = noctalia.json.decode(content) local decoded = noctalia.json.decode(raw)
if type(decoded) ~= "table" then return end if type(decoded) ~= "table" then return end
noctalia.state.set("colorHistory", decoded) noctalia.state.set("colorHistory", decoded)
end end
local function saveHistoryToDisk() local function saveHistoryToDisk()
local history = noctalia.state.get("colorHistory") or {} local history = noctalia.state.get("colorHistory") or {}
local encoded = noctalia.json.encode(history) local encoded = noctalia.json.encode(history)
if encoded ~= nil then if encoded ~= nil then
noctalia.writeFile(HISTORY_FILE, encoded) noctalia.writeFile(HISTORY_FILE, encoded)
end end
end end
local function loadSelectedFromDisk() local function loadSelectedFromDisk()
local content = noctalia.readFile(SELECTED_FILE) local raw = noctalia.readFile(SELECTED_FILE)
if content == nil or content == "" then return end if raw == nil or raw == "" then return end
local decoded = noctalia.json.decode(content) local decoded = noctalia.json.decode(raw)
if type(decoded) ~= "table" or decoded.hex == nil then return end if type(decoded) ~= "table" or decoded.hex == nil then return end
setSelected(decoded.hex, decoded.opacity or 1) setSelected(decoded.hex, decoded.opacity or 1)
end end
local function saveSelectedToDisk() local function saveSelectedToDisk()
local hex = noctalia.state.get("selectedColor") local hex = noctalia.state.get("selectedColor")
if hex == nil then return end if hex == nil then return end
local opacity = tonumber(noctalia.state.get("selectedOpacity")) or 1 local opacity = tonumber(noctalia.state.get("selectedOpacity")) or 1
local encoded = noctalia.json.encode({ hex = hex, opacity = opacity }) local encoded = noctalia.json.encode({ hex = hex, opacity = opacity })
if encoded ~= nil then if encoded ~= nil then
@@ -291,7 +289,7 @@ end
--============================================== --==============================================
function load() function load()
noctalia.mkdirAll(STATE_DIR) noctalia.mkdirAll(PERSISTENT_DIR)
loadHistoryFromDisk() loadHistoryFromDisk()
loadSelectedFromDisk() loadSelectedFromDisk()
end end
+41 -24
View File
@@ -1,31 +1,48 @@
{ {
"color_picker": "Color Picker",
"no_color_picked": "No color picked yet.",
"recent_colors": "Recent Colors",
"opacity": "Opacity",
"no_history": "No colors in history yet.",
"color_copied": "Color {color} copied.", "color_copied": "Color {color} copied.",
"color_picked": "Color {color} picked.", "color_picked": "Color {color} picked.",
"hyprpicker_not_installed": "hyprpicker is not installed.", "color_picker": "Color Picker",
"could_not_parse": "could not parse hyprpicker output.", "could_not_parse": "could not parse hyprpicker output.",
"glyph": "Glyph", "glyph": "Glyph",
"hex": "HEX", "hex": "HEX",
"hyprpicker_not_installed": "hyprpicker is not installed.",
"no_color_picked": "No color picked yet.",
"no_history": "No colors in history yet.",
"opacity": "Opacity",
"recent_colors": "Recent Colors",
"rgb": "RGB", "rgb": "RGB",
"settings": {
"settings.hyprpicker_format.label": "Default Format", "hyprpicker_cursor": {
"settings.hyprpicker_format.description": "Default color format to copy to clipboard (Hex or RGB).", "description": "Includes the cursor in the frozen screen preview.",
"settings.swatch_radius.label": "Swatches Corner Roundness", "label": "Show cursor"
"settings.swatch_radius.description": "Corner radius of the history swatches and current-color preview.", },
"settings.hyprpicker_lowercase.label": "Lowercase Hex", "hyprpicker_disable_preview": {
"settings.hyprpicker_lowercase.description": "Outputs the hexcode in lowercase.", "description": "Turns off the live color preview while picking.",
"settings.hyprpicker_scale.label": "Zoom Scale", "label": "Disable Live Preview"
"settings.hyprpicker_scale.description": "Zoom lens magnification, from 1 to 10.", },
"settings.hyprpicker_radius.label": "Zoom Radius", "hyprpicker_format": {
"settings.hyprpicker_radius.description": "Zoom lens circle radius in pixels, from 1 to 1000.", "description": "Default color format to copy to clipboard (Hex or RGB).",
"settings.hyprpicker_no_zoom.label": "Disable Zoom Lens", "label": "Default Format"
"settings.hyprpicker_no_zoom.description": "Turns off the magnifying zoom lens while picking.", },
"settings.hyprpicker_disable_preview.label": "Disable Live Preview", "hyprpicker_lowercase": {
"settings.hyprpicker_disable_preview.description": "Turns off the live color preview while picking.", "description": "Outputs the hexcode in lowercase.",
"settings.hyprpicker_cursor.label": "Show cursor", "label": "Lowercase Hex"
"settings.hyprpicker_cursor.description": "Includes the cursor in the frozen screen preview." },
} "hyprpicker_no_zoom": {
"description": "Turns off the magnifying zoom lens while picking.",
"label": "Disable Zoom Lens"
},
"hyprpicker_radius": {
"description": "Zoom lens circle radius in pixels, from 1 to 1000.",
"label": "Zoom Radius"
},
"hyprpicker_scale": {
"description": "Zoom lens magnification, from 1 to 10.",
"label": "Zoom Scale"
},
"swatch_radius": {
"description": "Corner radius of the history swatches and current-color preview.",
"label": "Swatches Corner Roundness"
}
}
}
+41 -24
View File
@@ -1,31 +1,48 @@
{ {
"color_picker": "Seletor de Cores",
"no_color_picked": "Nenhuma cor escolhida ainda.",
"recent_colors": "Cores Recentes",
"opacity": "Opacidade",
"no_history": "Ainda não há cores no histórico.",
"color_copied": "Cor {color} copiada.", "color_copied": "Cor {color} copiada.",
"color_picked": "Cor {color} selecionada.", "color_picked": "Cor {color} selecionada.",
"hyprpicker_not_installed": "hyprpicker não está instalado.", "color_picker": "Seletor de Cores",
"could_not_parse": "não foi possível extrair a saída do hyprpicker.", "could_not_parse": "não foi possível extrair a saída do hyprpicker.",
"glyph": "Símbolo", "glyph": "Símbolo",
"hex": "HEX", "hex": "HEX",
"hyprpicker_not_installed": "hyprpicker não está instalado.",
"no_color_picked": "Nenhuma cor escolhida ainda.",
"no_history": "Ainda não há cores no histórico.",
"opacity": "Opacidade",
"recent_colors": "Cores Recentes",
"rgb": "RGB", "rgb": "RGB",
"settings": {
"settings.hyprpicker_format.label": "Formato Padrão", "hyprpicker_cursor": {
"settings.hyprpicker_format.description": "Formato de cor padrão para copiar para a área de transferência (Hex ou RGB).", "description": "Inclui o cursor na prévia congelada da tela.",
"settings.swatch_radius.label": "Arredondamento de Cantos das Amostras", "label": "Mostrar cursor"
"settings.swatch_radius.description": "Raio dos cantos das amostras do histórico e da prévia da cor atual.", },
"settings.hyprpicker_lowercase.label": "Hexadecimal em Minúsculas", "hyprpicker_disable_preview": {
"settings.hyprpicker_lowercase.description": "Exibe o código hexadecimal em letras minúsculas.", "description": "Desativa a prévia da cor em tempo real ao selecionar.",
"settings.hyprpicker_scale.label": "Escala do Zoom", "label": "Desabilitar Prévia em Tempo Real"
"settings.hyprpicker_scale.description": "Ampliação da lupa, de 1 a 10.", },
"settings.hyprpicker_radius.label": "Raio do Zoom", "hyprpicker_format": {
"settings.hyprpicker_radius.description": "Raio do círculo da lupa em pixels, de 1 a 1000.", "description": "Formato de cor padrão para copiar para a área de transferência (Hex ou RGB).",
"settings.hyprpicker_no_zoom.label": "Desabilitar Lupa", "label": "Formato Padrão"
"settings.hyprpicker_no_zoom.description": "Dpesativa a lupa ao selecionar a cor.", },
"settings.hyprpicker_disable_preview.label": "Desabilitar Prévia em Tempo Real", "hyprpicker_lowercase": {
"settings.hyprpicker_disable_preview.description": "Desativa a prévia da cor em tempo real ao selecionar.", "description": "Exibe o código hexadecimal em letras minúsculas.",
"settings.hyprpicker_cursor.label": "Mostrar cursor", "label": "Hexadecimal em Minúsculas"
"settings.hyprpicker_cursor.description": "Inclui o cursor na prévia congelada da tela." },
} "hyprpicker_no_zoom": {
"description": "Dpesativa a lupa ao selecionar a cor.",
"label": "Desabilitar Lupa"
},
"hyprpicker_radius": {
"description": "Raio do círculo da lupa em pixels, de 1 a 1000.",
"label": "Raio do Zoom"
},
"hyprpicker_scale": {
"description": "Ampliação da lupa, de 1 a 10.",
"label": "Escala do Zoom"
},
"swatch_radius": {
"description": "Raio dos cantos das amostras do histórico e da prévia da cor atual.",
"label": "Arredondamento de Cantos das Amostras"
}
}
}
+17 -7
View File
@@ -1,9 +1,19 @@
{ {
"notify.applied": "Wallpaper applied", "notify": {
"notify.failed": "Daily wallpaper failed", "applied": "Wallpaper applied",
"settings.locale.description": "Bing market locale such as en-US, de-DE, or fr-FR. Empty uses en-US.", "failed": "Daily wallpaper failed"
"settings.locale.label": "Locale", },
"settings.source.label": "Source", "settings": {
"settings.source.options.bing": "Bing", "locale": {
"settings.source.options.nasa": "NASA" "description": "Bing market locale such as en-US, de-DE, or fr-FR. Empty uses en-US.",
"label": "Locale"
},
"source": {
"label": "Source",
"options": {
"bing": "Bing",
"nasa": "NASA"
}
}
}
} }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 whyoolw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+110
View File
@@ -0,0 +1,110 @@
# DropWall
Drag a local image anywhere onto the desktop to set it as the wallpaper in
Noctalia v5.
The image is applied through Noctalia's own wallpaper API, so the regular fill
mode, fill color, transitions, and wallpaper state remain in effect.
## Plugin
| Field | Value |
| --- | --- |
| ID | `whyoolw/dropwall` |
| Entries | Service: `service` (`service.luau`) |
DropWall is a headless service plugin. It does not add a bar widget or panel;
enabling the plugin starts the desktop drop target service.
## Usage
1. Enable `whyoolw/dropwall` from Noctalia's plugin store or with
`noctalia msg plugins enable whyoolw/dropwall`.
2. Drag a local image file onto the bare desktop.
3. Drop it on any monitor to apply it as the wallpaper through Noctalia's
wallpaper settings.
4. Optional: open **Settings -> Plugins -> DropWall** to enable per-monitor
drops, safe copying into the wallpaper directory, notifications, or the
alternate `bottom` layer.
## Features
- Full-desktop drop targets on every connected monitor.
- Optional per-monitor application based on the monitor receiving the drop.
- Optional safe copy into Noctalia's wallpaper directory.
- A subtle dashed highlight while a file is dragged over the desktop.
- Automatic monitor hotplug handling and helper recovery.
## How it works
- A headless service opens one long-lived stream to
`dropwall_supervisor.py`. The supervisor owns a GTK3 + gtk-layer-shell
worker and restarts it after an unexpected exit without consuming extra
Noctalia stream slots.
- The worker keeps one fully transparent layer-shell surface per monitor,
anchored to all edges. A runtime lock, parent-death signals, and pipe
heartbeats prevent orphaned or duplicate workers.
- Dragging a file over the desktop shows a subtle dashed drop highlight.
- On drop, the worker accepts only a local regular file with a supported
extension (`jpg`, `jpeg`, `png`, `webp`, `bmp`, or `gif`). It reports a
percent-encoded path and the monitor's logical geometry to the service.
- The service matches that geometry against `noctalia.outputs()` and applies
the image with `noctalia.setWallpaper()`. Ambiguous per-monitor matches fail
safely instead of changing every output.
- When copying is enabled, the service resolves the current theme's wallpaper
directory for that drop and starts `dropwall_copy.py`. The copier writes a
private hidden temporary file, flushes it, then publishes the complete file
atomically without replacing anything. `photo-1.jpg`, `photo-2.jpg`, and so
on are used for name collisions.
## Requirements
- `python3`
- Python GObject bindings (`python-gobject` / `python3-gi`)
- GTK 3 and the GTK Layer Shell typelib (`gtk3`, `gtk-layer-shell`)
- A compositor with wlr-layer-shell support (niri, Hyprland, sway, …)
Package names vary between distributions. Install the packages that provide
Python 3, PyGObject, GTK 3, and the GTK Layer Shell typelib on your system.
## Settings
| Setting | Default | Meaning |
| --- | --- | --- |
| Per-monitor drop | off | Set only on the monitor you dropped on; off = system behavior |
| Copy into wallpaper directory | off | Create a non-overwriting copy in Noctalia's wallpaper directory before applying |
| Notify on set | on | Notification when applied |
| Drop surface layer | background | Use `bottom` if drops do not register; the service restarts automatically |
## Process, filesystem, and network behavior
DropWall keeps two local Python processes running: a small supervisor and its
GTK worker. It writes a PID lock named `noctalia-dropwall.lock` in
`$XDG_RUNTIME_DIR`. With **Copy into wallpaper directory** enabled, each drop
starts one short-lived Python copier and writes a mode-0600 image to the
directory returned by Noctalia. A completed copy appears atomically and
existing files are never overwritten. The copier watches the exact GTK worker
that accepted the drop and cleans up if the plugin is stopped or reloaded.
Before a copy, the copier removes only
owned `.dropwall-copy-*.tmp` files older than 24 hours that an unclean shutdown
may have left in that directory. With copying disabled, Noctalia keeps
referring to the original file, so moving or deleting that file can break the
wallpaper.
DropWall makes no network requests and never downloads or executes remote
code.
## Notes
- The drop surface accepts pointer input over the bare desktop (that's what
makes Wayland DnD target it). Noctalia desktop widgets live on their own
surfaces and are unaffected.
- Files dragged from browsers as remote URLs (not `file://`) are ignored —
save the image first.
- If the highlight does not appear, switch **Drop surface layer** from
`background` to `bottom`. If startup still fails, check Noctalia's log for
messages prefixed with `dropwall helper:` and verify the dependencies above.
## License
MIT — see [LICENSE](LICENSE).
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Atomically copy one dropped image without replacing an existing file."""
import argparse
import ctypes
import os
import select
import signal
import stat
import sys
import tempfile
import time
import urllib.parse
TEMP_PREFIX = ".dropwall-copy-"
TEMP_SUFFIX = ".tmp"
STALE_SECONDS = 24 * 60 * 60
ACTIVE_TEMP = None
COPY_CHUNK = 1024 * 1024
# Exit immediately if Noctalia closes the process pipe.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
def arm_parent_death_signal():
"""Ask Linux to terminate this copy if Noctalia disappears."""
parent = os.getppid()
try:
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
return
if os.getppid() != parent:
os.kill(os.getpid(), signal.SIGTERM)
except (AttributeError, OSError):
return
def encode_path(path):
return urllib.parse.quote_from_bytes(os.fsencode(path), safe="")
def remove_active_temp():
global ACTIVE_TEMP
if ACTIVE_TEMP:
try:
os.unlink(ACTIVE_TEMP)
except OSError:
pass
ACTIVE_TEMP = None
def terminate(signum, _frame):
remove_active_temp()
os._exit(128 + signum)
class WorkerLease:
"""A pidfd tied to the GTK worker that accepted this drop."""
def __init__(self, pid):
if not hasattr(os, "pidfd_open"):
raise OSError("this Linux/Python build does not support pidfd_open")
self.fd = os.pidfd_open(pid, 0)
self.poller = select.poll()
self.poller.register(self.fd, select.POLLIN | select.POLLHUP | select.POLLERR)
self.check()
def check(self):
if self.poller.poll(0):
raise BrokenPipeError("DropWall worker stopped during the copy")
def close(self):
os.close(self.fd)
def cleanup_stale_temps(directory):
"""Remove only old, owned temporary files left by interrupted copies."""
cutoff = time.time() - STALE_SECONDS
try:
names = os.listdir(directory)
except OSError:
return
for name in names:
if not (name.startswith(TEMP_PREFIX) and name.endswith(TEMP_SUFFIX)):
continue
path = os.path.join(directory, name)
try:
info = os.lstat(path)
if info.st_uid == os.getuid() and stat.S_ISREG(info.st_mode) and info.st_mtime < cutoff:
os.unlink(path)
except OSError:
continue
def is_inside(path, directory):
try:
return os.path.commonpath((path, directory)) == directory
except ValueError:
return False
def publish_unique(temp_path, source, directory, lease):
filename = os.path.basename(source)
stem, suffix = os.path.splitext(filename)
stem = stem or "wallpaper"
for counter in range(10000):
candidate_name = filename if counter == 0 else "%s-%d%s" % (stem, counter, suffix)
candidate = os.path.join(directory, candidate_name)
try:
lease.check()
# The hard link exposes the already-complete inode atomically and
# fails if candidate exists. It can never replace user data.
os.link(temp_path, candidate, follow_symlinks=False)
return candidate
except FileExistsError:
continue
raise FileExistsError("could not allocate a unique destination filename")
def copy_atomic(source, directory, lease):
global ACTIVE_TEMP
real_directory = os.path.realpath(directory)
if not os.path.isdir(real_directory):
raise NotADirectoryError("wallpaper directory does not exist")
source_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0)
source_fd = os.open(source, source_flags)
with os.fdopen(source_fd, "rb") as source_file:
source_info = os.fstat(source_file.fileno())
if not stat.S_ISREG(source_info.st_mode):
raise OSError("the dropped path is not a regular file")
# Resolve the descriptor we actually validated, not a pathname that
# could have been swapped after open().
real_source = os.path.realpath("/proc/self/fd/%d" % source_file.fileno())
if is_inside(real_source, real_directory):
return real_source
cleanup_stale_temps(real_directory)
temp_fd, temp_path = tempfile.mkstemp(
prefix=TEMP_PREFIX,
suffix=TEMP_SUFFIX,
dir=real_directory,
)
ACTIVE_TEMP = temp_path
try:
with os.fdopen(temp_fd, "wb") as temp_file:
while True:
lease.check()
chunk = source_file.read(COPY_CHUNK)
if not chunk:
break
temp_file.write(chunk)
temp_file.flush()
os.fsync(temp_file.fileno())
# Keep copies private even if the source was more permissive.
os.chmod(temp_path, 0o600)
return publish_unique(temp_path, source, real_directory, lease)
finally:
remove_active_temp()
def main():
arm_parent_death_signal()
parser = argparse.ArgumentParser(description="Safely copy one DropWall image")
parser.add_argument("--lease-pid", type=int, required=True)
parser.add_argument("source")
parser.add_argument("directory")
args = parser.parse_args()
for signum in (signal.SIGINT, signal.SIGTERM):
signal.signal(signum, terminate)
lease = None
try:
lease = WorkerLease(args.lease_pid)
destination = copy_atomic(args.source, args.directory, lease)
except Exception as error:
print(str(error).replace("\n", " "), file=sys.stderr, flush=True)
return 1
finally:
if lease is not None:
lease.close()
print("COPIED\t%s" % encode_path(destination), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""DropWall helper: transparent layer-shell drop targets, one per monitor.
Owned by dropwall_supervisor.py, which keeps one worker attached to the single
stream opened by the DropWall Noctalia service.
Protocol, one line per event on stdout:
READY\t<n> n drop surfaces created
ALIVE heartbeat for stream/orphan detection
DROP\t<x>\t<y>\t<w>\t<h>\t<pid>\t<path> image dropped; path is percent-encoded
INVALID\t<path> unsupported filename extension
MISSING\t<path> dropped path is not a regular file
ERR\t<message> non-fatal problem
"""
import argparse
import ctypes
import fcntl
import os
import signal
import stat
import sys
import threading
import urllib.parse
# Python ignores SIGPIPE by default. Restoring the Unix behavior makes an
# orphaned helper exit on its next heartbeat when Noctalia closes the stream.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
try:
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
gi.require_version("GtkLayerShell", "0.1")
from gi.repository import Gdk, GLib, Gtk, GtkLayerShell # noqa: E402
except (ImportError, ValueError) as error:
message = str(error).replace("\n", " ")
print("ERR\tGTK dependencies could not be loaded: %s" % message, flush=True)
raise SystemExit(2)
CSS = b"""
window { background-color: rgba(0, 0, 0, 0); }
.dropzone {
background-color: rgba(0, 0, 0, 0);
border: 3px dashed rgba(0, 0, 0, 0);
border-radius: 18px;
margin: 14px;
transition: background-color 150ms ease, border-color 150ms ease;
}
.dropzone.hover {
background-color: rgba(128, 128, 128, 0.16);
border-color: rgba(255, 255, 255, 0.55);
}
"""
LAYERS = {
"background": GtkLayerShell.Layer.BACKGROUND,
"bottom": GtkLayerShell.Layer.BOTTOM,
}
VALID_EXTENSIONS = {"jpg", "jpeg", "png", "webp", "bmp", "gif"}
HEARTBEAT_SECONDS = 5
EMIT_LOCK = threading.Lock()
INSTANCE_LOCK = None
def emit(line):
with EMIT_LOCK:
print(line, flush=True)
def arm_parent_death_signal():
"""Ask Linux to terminate us if the process that spawned us disappears."""
parent = os.getppid()
try:
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
return
# Close the small race where the parent dies immediately before prctl.
if os.getppid() != parent:
os.kill(os.getpid(), signal.SIGTERM)
except (AttributeError, OSError):
# The heartbeat/SIGPIPE path remains the portable fallback.
return
def acquire_instance_lock():
"""Keep at most one DropWall target alive in this user session."""
global INSTANCE_LOCK
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if not runtime_dir:
emit("ERR\tXDG_RUNTIME_DIR is not set")
return False
lock_path = os.path.join(runtime_dir, "noctalia-dropwall.lock")
flags = os.O_RDWR | os.O_CREAT
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(lock_path, flags, 0o600)
info = os.fstat(fd)
if info.st_uid != os.getuid() or not stat.S_ISREG(info.st_mode):
raise PermissionError("unsafe lock file")
os.fchmod(fd, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(fd)
emit("BUSY\tanother DropWall helper is already running")
return False
except OSError as error:
if "fd" in locals():
os.close(fd)
emit("ERR\tcould not acquire the runtime lock: %s" % error)
return False
INSTANCE_LOCK = os.fdopen(fd, "w", encoding="ascii")
INSTANCE_LOCK.write("%d\n" % os.getpid())
INSTANCE_LOCK.flush()
return True
def selection_to_path(data):
"""Extract the first local file path from a drop's selection data."""
uris = list(data.get_uris() or [])
if not uris:
text = data.get_text()
if text:
uris = [part for part in text.split("\n") if part.strip()]
for uri in uris:
uri = uri.strip()
if uri.startswith("file://"):
parsed = urllib.parse.urlsplit(uri)
if parsed.netloc not in ("", "localhost"):
continue
path = os.fsdecode(urllib.parse.unquote_to_bytes(parsed.path))
if uri.startswith("/"):
path = uri
elif not uri.startswith("file://"):
continue
if "\0" not in path and os.path.isabs(path):
return path
return None
def encode_path(path):
"""Encode filesystem bytes as ASCII so filenames cannot forge events."""
return urllib.parse.quote_from_bytes(os.fsencode(path), safe="")
def process_drop(path, geometry):
"""Validate a drop away from the GTK event loop, then report it."""
encoded_path = encode_path(path)
try:
info = os.stat(path)
except (OSError, ValueError):
emit("MISSING\t%s" % encoded_path)
return
if not stat.S_ISREG(info.st_mode):
emit("MISSING\t%s" % encoded_path)
return
extension = os.path.splitext(path)[1].lower().lstrip(".")
if extension not in VALID_EXTENSIONS:
emit("INVALID\t%s" % encoded_path)
return
encoded_path = encode_path(path)
x, y, width, height = geometry
emit("DROP\t%d\t%d\t%d\t%d\t%d\t%s" % (x, y, width, height, os.getpid(), encoded_path))
class DropWindow(Gtk.Window):
def __init__(self, monitor, layer):
super().__init__(type=Gtk.WindowType.TOPLEVEL)
self.monitor = monitor
visual = self.get_screen().get_rgba_visual()
if visual is not None:
self.set_visual(visual)
GtkLayerShell.init_for_window(self)
GtkLayerShell.set_layer(self, layer)
GtkLayerShell.set_monitor(self, monitor)
GtkLayerShell.set_namespace(self, "dropwall")
for edge in (
GtkLayerShell.Edge.LEFT,
GtkLayerShell.Edge.RIGHT,
GtkLayerShell.Edge.TOP,
GtkLayerShell.Edge.BOTTOM,
):
GtkLayerShell.set_anchor(self, edge, True)
# Cover the full output, including space reserved by bars/docks.
GtkLayerShell.set_exclusive_zone(self, -1)
self.zone = Gtk.Box()
self.zone.get_style_context().add_class("dropzone")
self.add(self.zone)
self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
self.drag_dest_add_uri_targets()
self.drag_dest_add_text_targets()
self.connect("drag-motion", self.on_drag_motion)
self.connect("drag-leave", self.on_drag_leave)
self.connect("drag-data-received", self.on_drag_data_received)
self.show_all()
def on_drag_motion(self, _widget, _context, _x, _y, _time):
self.zone.get_style_context().add_class("hover")
return False # let the default DestDefaults handler ack the drag
def on_drag_leave(self, _widget, _context, _time):
self.zone.get_style_context().remove_class("hover")
def on_drag_data_received(self, _widget, _context, _x, _y, data, _info, _time):
self.zone.get_style_context().remove_class("hover")
path = selection_to_path(data)
if not path:
emit("ERR\tdrop carried no usable local file path")
return
geo = self.monitor.get_geometry()
geometry = (geo.x, geo.y, geo.width, geo.height)
threading.Thread(
target=process_drop,
args=(path, geometry),
daemon=True,
).start()
class App:
def __init__(self, layer):
self.layer = layer
self.windows = []
self.rebuild_pending = False
provider = Gtk.CssProvider()
provider.load_from_data(CSS)
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
display = Gdk.Display.get_default()
display.connect("monitor-added", self.schedule_rebuild)
display.connect("monitor-removed", self.schedule_rebuild)
self.build_windows()
GLib.timeout_add_seconds(HEARTBEAT_SECONDS, self.heartbeat)
def build_windows(self):
for win in self.windows:
win.destroy()
self.windows = []
display = Gdk.Display.get_default()
for i in range(display.get_n_monitors()):
monitor = display.get_monitor(i)
if monitor is not None:
self.windows.append(DropWindow(monitor, self.layer))
emit("READY\t%d" % len(self.windows))
def heartbeat(self):
emit("ALIVE")
return True
def schedule_rebuild(self, *_args):
# Debounce: hotplug fires added+removed bursts during mode changes.
if self.rebuild_pending:
return
self.rebuild_pending = True
def do_rebuild():
self.rebuild_pending = False
self.build_windows()
return False
GLib.timeout_add(500, do_rebuild)
def main():
parser = argparse.ArgumentParser(description="DropWall layer-shell drop helper")
parser.add_argument("--layer", choices=sorted(LAYERS), default="background")
args = parser.parse_args()
arm_parent_death_signal()
if not acquire_instance_lock():
return 3
try:
if Gdk.Display.get_default() is None:
emit("ERR\tcould not connect to the Wayland display")
return 1
if not GtkLayerShell.is_supported():
emit("ERR\tlayer-shell is not supported by this compositor")
return 1
App(LAYERS[args.layer])
Gtk.main()
return 0
except Exception as error:
emit("ERR\tGTK helper startup failed: %s" % str(error).replace("\n", " "))
return 1
if __name__ == "__main__":
sys.exit(main())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Keep one DropWall GTK target attached to a single Noctalia stream."""
import argparse
import ctypes
import os
import signal
import subprocess
import sys
import time
# Exit immediately when Noctalia closes runStream's stdout pipe.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
def arm_parent_death_signal():
"""Ask Linux to terminate us if Noctalia disappears."""
parent = os.getppid()
try:
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
return
if os.getppid() != parent:
os.kill(os.getpid(), signal.SIGTERM)
except (AttributeError, OSError):
return
def emit(line):
print(line, flush=True)
def worker_command(args):
helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dropwall_helper.py")
return [sys.executable, "-B", helper, "--layer", args.layer]
def run_worker(command):
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
assert process.stdout is not None
for line in process.stdout:
emit(line.rstrip("\n"))
return process.wait()
def main():
parser = argparse.ArgumentParser(description="DropWall helper supervisor")
parser.add_argument("--layer", choices=("background", "bottom"), default="background")
args = parser.parse_args()
arm_parent_death_signal()
command = worker_command(args)
lock_retry_seconds = 2
while True:
try:
exit_code = run_worker(command)
except OSError as error:
emit("ERR\tcould not start the GTK helper: %s" % str(error).replace("\n", " "))
exit_code = 127
emit("RESTART\t%d" % exit_code)
if exit_code == 3:
# A collision normally means the previous runtime is still
# shutting down. Back off if it is a genuinely persistent owner.
time.sleep(lock_retry_seconds)
lock_retry_seconds = min(lock_retry_seconds * 2, 60)
else:
lock_retry_seconds = 2
time.sleep(30)
if __name__ == "__main__":
sys.exit(main())
+61
View File
@@ -0,0 +1,61 @@
# DropWall — drag & drop an image anywhere onto the desktop to set it as the
# wallpaper.
#
# A headless [[service]] owns a Python supervisor and a GTK3 + gtk-layer-shell
# worker that keeps one transparent layer-shell surface per monitor as a drop
# target. When an image is dropped, the worker reports the file and monitor
# geometry; the service resolves the output connector and applies it through
# noctalia.setWallpaper(), just like the built-in wallpaper panel.
#
# External requirements: python3, python-gobject, gtk3, gtk-layer-shell.
id = "whyoolw/dropwall"
name = "DropWall"
version = "1.0.0"
plugin_api = 3
author = "whyoolw"
license = "MIT"
dependencies = ["python3", "python-gobject", "gtk3", "gtk-layer-shell"]
tags = ["desktop", "wallpaper"]
icon = "image"
description = "Drag and drop an image onto the desktop to set it as wallpaper using Noctalia's wallpaper settings."
[[setting]]
key = "per_monitor"
type = "bool"
label_key = "settings.per_monitor.label"
description_key = "settings.per_monitor.description"
default = false
[[setting]]
key = "copy_to_wallpaper_dir"
type = "bool"
label_key = "settings.copy_to_wallpaper_dir.label"
description_key = "settings.copy_to_wallpaper_dir.description"
default = false
[[setting]]
key = "notify_on_set"
type = "bool"
label_key = "settings.notify_on_set.label"
description_key = "settings.notify_on_set.description"
default = true
# Which layer-shell layer the invisible drop surface lives on. "background"
# sits next to the wallpaper itself and is the least intrusive; switch to
# "bottom" if drops don't register on your compositor because the wallpaper
# surface swallows them. Changing it restarts the service automatically.
[[setting]]
key = "layer"
type = "select"
label_key = "settings.layer.label"
description_key = "settings.layer.description"
default = "background"
options = [
{ value = "background", label_key = "settings.layer.options.background" },
{ value = "bottom", label_key = "settings.layer.options.bottom" },
]
[[service]]
id = "service"
entry = "service.luau"
+282
View File
@@ -0,0 +1,282 @@
--!nonstrict
-- DropWall service: owns one long-lived helper supervisor stream and applies
-- dropped images as wallpapers.
--
-- The supervisor owns a GTK3 + gtk-layer-shell worker with one transparent
-- surface per monitor. Paths are percent-encoded on its stdout protocol, then
-- decoded here before going through noctalia.setWallpaper().
-- Same whitelist as the host wallpaper scanner (wallpaper.cpp).
local VALID_EXT = { jpg = true, jpeg = true, png = true, webp = true, bmp = true, gif = true }
local HELPER_ERROR_COOLDOWN = 300
local helperErrorNotified = false
local lastHelperErrorAt = 0
local busyLogged = false
local busyCount = 0
local copyInProgress = false
local pendingCopy = nil
local helperStreamStarted = false
local function cfg(key)
return noctalia.getConfig(key)
end
local function basename(path)
return path:match("([^/]+)$") or path
end
local function shellQuote(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
local function now()
return tonumber(noctalia.formatTime("%s")) or 0
end
local function reportHelperError(message)
noctalia.log("dropwall helper: " .. message)
local stamp = now()
if not helperErrorNotified or (stamp > 0 and stamp - lastHelperErrorAt >= HELPER_ERROR_COOLDOWN) then
helperErrorNotified = true
lastHelperErrorAt = stamp
noctalia.notifyError(noctalia.tr("notify.helper_error_title"), message)
end
end
local function decodePath(value)
if type(value) ~= "string" or value == "" then
return nil
end
local decoded = noctalia.string.urlDecode(value)
if type(decoded) ~= "string" or decoded == "" or decoded:find("%z") then
return nil
end
return decoded
end
-- Resolve the connector of the monitor the image was dropped on by matching
-- the helper-reported logical geometry against the host's output list.
local function findConnector(x, y, w, h)
local outputs = noctalia.outputs()
local exact = nil
local exactCount = 0
for i = 1, #outputs do
local o = outputs[i]
if o.x == x and o.y == y and o.width == w and o.height == h then
exact = o.name
exactCount = exactCount + 1
end
end
-- Mirrored outputs may share identical geometry. Refuse an ambiguous match
-- instead of choosing one arbitrarily.
if exactCount == 1 then
return exact
elseif exactCount > 1 then
return nil
end
return nil
end
local function applyWallpaper(path, connector)
if cfg("per_monitor") then
if not connector then
noctalia.notifyError(noctalia.tr("notify.output_error_title"), basename(path))
return
end
noctalia.setWallpaper(connector, path)
else
-- The host applies its normal all-output wallpaper behavior.
noctalia.setWallpaper(path)
end
if cfg("notify_on_set") then
noctalia.notify(noctalia.tr("notify.set_title"), basename(path))
end
end
local function applyOriginalAfterCopyError(path, connector, message)
noctalia.log("dropwall copy: " .. message)
noctalia.notifyError(noctalia.tr("notify.copy_dir_title"), noctalia.tr("notify.copy_failed"))
applyWallpaper(path, connector)
end
local function copyAndApply(path, connector, leasePid)
if copyInProgress then
-- Bound copy concurrency to one process and retain only the latest queued
-- drop. It will be copied as soon as the active one finishes.
pendingCopy = { path = path, connector = connector, leasePid = leasePid }
noctalia.log("dropwall copy: queued latest drop while a copy is active")
return
end
-- This is theme-mode dependent, so resolve it for every drop rather than
-- pinning the directory when the service starts.
local wallpaperDir = noctalia.wallpaperDirectory()
if type(wallpaperDir) ~= "string" or wallpaperDir == "" then
noctalia.notifyError(noctalia.tr("notify.copy_dir_title"), noctalia.tr("notify.copy_dir_missing"))
applyWallpaper(path, connector)
return
end
local pluginDir = noctalia.pluginDir()
if type(pluginDir) ~= "string" or pluginDir == "" then
applyOriginalAfterCopyError(path, connector, "plugin directory is unavailable")
return
end
local copier = pluginDir .. "/dropwall_copy.py"
if not noctalia.fileExists(copier) then
applyOriginalAfterCopyError(path, connector, "copy helper is missing")
return
end
local cmd = "exec python3 -B " .. shellQuote(copier)
.. " --lease-pid " .. shellQuote(leasePid)
.. " " .. shellQuote(path) .. " " .. shellQuote(wallpaperDir)
copyInProgress = true
local accepted = noctalia.runAsync(cmd, function(result)
copyInProgress = false
if type(result) ~= "table" or result.exitCode ~= 0 or result.timedOut or result.stdoutTruncated then
local detail = type(result) == "table" and noctalia.string.trim(result.stderr or "") or "no result"
applyOriginalAfterCopyError(path, connector, detail ~= "" and detail or "copy process failed")
else
local output = noctalia.string.trim(result.stdout or "")
local encoded = output:match("^COPIED\t([^\t\r\n]+)$")
local copied = decodePath(encoded)
local copiedInfo = copied and noctalia.fileInfo(copied) or nil
if not copied or type(copiedInfo) ~= "table" or copiedInfo.isDir then
applyOriginalAfterCopyError(path, connector, "copy helper returned an invalid destination")
else
applyWallpaper(copied, connector)
end
end
local pending = pendingCopy
pendingCopy = nil
if pending then
copyAndApply(pending.path, pending.connector, pending.leasePid)
end
end, 60000)
if not accepted then
copyInProgress = false
applyOriginalAfterCopyError(path, connector, "Noctalia rejected the copy process")
end
end
local function handleDrop(path, connector, leasePid)
local ext = path:match("%.(%w+)$")
if not ext or not VALID_EXT[ext:lower()] then
noctalia.notifyError(noctalia.tr("notify.invalid_title"), basename(path))
return
end
if not noctalia.fileExists(path) then
noctalia.notifyError(noctalia.tr("notify.missing_title"), path)
return
end
local info = noctalia.fileInfo(path)
if type(info) ~= "table" or info.isDir then
noctalia.notifyError(noctalia.tr("notify.missing_title"), path)
return
end
if cfg("per_monitor") and not connector then
noctalia.notifyError(noctalia.tr("notify.output_error_title"), basename(path))
return
end
if cfg("copy_to_wallpaper_dir") then
copyAndApply(path, connector, leasePid)
else
applyWallpaper(path, connector)
end
end
local function onHelperLine(line)
if line:sub(1, 5) == "DROP\t" then
local x, y, w, h, pid, encoded = line:match("^DROP\t(-?%d+)\t(-?%d+)\t(%d+)\t(%d+)\t(%d+)\t([^\t]+)$")
local path = decodePath(encoded)
local leasePid = tonumber(pid)
if path and leasePid and leasePid > 1 then
handleDrop(path, findConnector(tonumber(x), tonumber(y), tonumber(w), tonumber(h)), leasePid)
else
noctalia.log("dropwall: malformed DROP line: " .. line)
end
elseif line:sub(1, 8) == "INVALID\t" then
local path = decodePath(line:sub(9))
noctalia.notifyError(noctalia.tr("notify.invalid_title"), path and basename(path) or "")
elseif line:sub(1, 8) == "MISSING\t" then
local path = decodePath(line:sub(9))
noctalia.notifyError(noctalia.tr("notify.missing_title"), path or "")
elseif line:sub(1, 4) == "ERR\t" then
reportHelperError(line:sub(5))
elseif line:sub(1, 5) == "BUSY\t" then
busyCount = busyCount + 1
if not busyLogged then
noctalia.log("dropwall helper: " .. line:sub(6))
busyLogged = true
end
if busyCount == 3 then
reportHelperError(noctalia.tr("notify.helper_busy"))
end
elseif line:sub(1, 6) == "READY\t" then
local count = tonumber(line:sub(7)) or 0
if count > 0 then
helperErrorNotified = false
busyLogged = false
busyCount = 0
else
reportHelperError(noctalia.tr("notify.no_outputs"))
end
elseif line:sub(1, 8) == "RESTART\t" then
local exitCode = line:sub(9)
if exitCode ~= "3" then
noctalia.log("dropwall: helper exited (code " .. exitCode .. "); supervisor will restart it")
end
elseif line ~= "ALIVE" and line ~= "" then
noctalia.log("dropwall helper output: " .. line)
end
end
local function startHelper()
if not noctalia.commandExists("python3") then
reportHelperError(noctalia.tr("notify.python_missing"))
return
end
local layer = tostring(cfg("layer") or "background")
if layer ~= "background" and layer ~= "bottom" then
layer = "background"
end
local pluginDir = noctalia.pluginDir()
if type(pluginDir) ~= "string" or pluginDir == "" then
reportHelperError(noctalia.tr("notify.plugin_dir_missing"))
return
end
local script = pluginDir .. "/dropwall_supervisor.py"
if not noctalia.fileExists(script) then
reportHelperError(noctalia.tr("notify.helper_file_missing"))
return
end
local cmd = "exec python3 -B " .. shellQuote(script) .. " --layer " .. shellQuote(layer)
cmd = cmd .. " 2>&1"
if noctalia.runStream(cmd, onHelperLine) then
helperStreamStarted = true
else
reportHelperError(noctalia.tr("notify.helper_start_failed"))
end
end
-- Boot.
startHelper()
-- If python3 was missing or the initial stream launch was rejected, retry
-- without ever opening more than the one long-lived supervisor stream.
function update()
noctalia.setUpdateInterval(60000)
if not helperStreamStarted then
startHelper()
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+41
View File
@@ -0,0 +1,41 @@
{
"notify": {
"copy_dir_missing": "No wallpaper directory is configured; the original file will be used.",
"copy_dir_title": "Wallpaper was not copied",
"copy_failed": "The safe copy failed; the original file will be used.",
"helper_busy": "Another DropWall helper is still running. The plugin will keep retrying.",
"helper_error_title": "DropWall helper error",
"helper_file_missing": "The DropWall supervisor file is missing.",
"helper_start_failed": "Noctalia could not open the helper stream.",
"invalid_title": "Not an image (jpg, png, webp, bmp, gif)",
"missing_title": "Dropped file not found",
"no_outputs": "The helper could not create a drop target for any output.",
"output_error_title": "Could not identify the target monitor",
"plugin_dir_missing": "Noctalia did not provide the plugin directory.",
"python_missing": "python3 is not available on PATH.",
"set_title": "Wallpaper set"
},
"settings": {
"copy_to_wallpaper_dir": {
"description": "Copy the dropped image into the system wallpaper directory before applying. Existing files are never replaced; a numeric suffix is added when needed.",
"label": "Copy into wallpaper directory"
},
"layer": {
"description": "Layer-shell layer for the invisible drop surface. \"Background\" is the least intrusive; use \"Bottom\" if drops don't register.",
"label": "Drop surface layer",
"options": {
"background": "Background",
"bottom": "Bottom"
}
},
"notify_on_set": {
"description": "Show a notification when a dropped image is applied.",
"label": "Notify on set"
},
"per_monitor": {
"description": "Set the wallpaper only on the monitor the image was dropped on. When off, apply it to every output.",
"label": "Per-monitor drop"
}
},
"title": "DropWall"
}
+130
View File
@@ -0,0 +1,130 @@
# File Search
A [noctalia](https://github.com/noctalia-dev/noctalia) v5 bar plugin: fuzzy
search files and folders as you type, with [fzf](https://github.com/junegunn/fzf)
as the matching subsystem. Click the bar glyph to open a search panel; picking
a result opens it with the system MIME association (`xdg-open`) — directories
open in your file manager.
## Plugin
| Field | Value |
| --- | --- |
| ID | `nightwatch75/file-search` |
| Entries | Bar widget: `file-search`; panel: `panel`; launcher provider: `launcher` |
| Launcher Prefix | `/fs` |
## Usage
Add the `file-search` widget from Noctalia's widget picker and click it to
open the search panel. You can also open the panel directly or bind it in
your compositor:
```sh
noctalia msg panel-toggle nightwatch75/file-search:panel
```
| Action | Effect |
|--------------|-------------------------------------------------|
| Left click | Open/close the search panel |
| Right click | Open the search folder in the file manager |
| Middle click | Copy the search folder path to the clipboard |
In the panel:
| Key | Action |
|---------|-------------------------------------|
| `Enter` | Open the top match |
| `Esc` | Close the panel (noctalia default) |
In the noctalia launcher (keyboard-first flow, native navigation):
| Key | Action |
|-------------|-------------------------------------------|
| `/fs <text>`| Fuzzy search files and folders |
| `↑` / `↓` | Move through the results |
| `Enter` | Open the selected result (MIME/xdg-open) |
With an empty `/fs` query the list also offers *Rebuild search index*; the
index is shared with the panel and built on demand when missing.
## Features
- Live results while you type: the search folder is walked once with `find`
into a cache, then every keystroke is fuzzy-matched through
`fzf --filter`, so typing stays responsive even on large trees
- Configurable bar glyph, search folder (defaults to `~`), excluded folder
names (`.git, node_modules, .cache, .venv` by default, matched anywhere in
the tree), hidden entries on/off, max results
- `Enter` opens the top match; every result row opens on click via the
system MIME association — files in their default app, folders in the file
manager
- Launcher provider for a keyboard-first flow: type `/fs <text>` in the
noctalia launcher and navigate the results with the native arrow keys +
`Enter` (plugin panels cannot receive arrow keys in the current Luau API,
so the launcher is the keyboard way to browse results)
- Folder results are marked with a trailing `/` and a folder glyph
- Index rebuilds automatically when the relevant settings change, and on
demand via the panel's refresh button (external file changes are picked up
on rebuild)
- Panel placement (attached/floating), position and open-near-click are the
standard per-panel settings noctalia exposes in Settings → Plugins
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `search_folder` | `folder` | *(empty)* | Root folder the search indexes. Empty = your home folder. |
| `exclude_dirs` | `string` | `.git, node_modules, .cache, .venv` | Folder names skipped while indexing, separated by `,` or `;`, matched anywhere in the tree. |
| `show_hidden` | `bool` | `false` | Index files and folders whose name starts with a dot. |
| `max_results` | `int` | `50` | How many matches the panel lists at most (10–200). |
| `glyph` (widget) | `glyph` | `search` | Icon shown on the bar. |
## Requirements
- noctalia ≥ 5.0.0
- [`fzf`](https://github.com/junegunn/fzf) — the fuzzy matcher
- `find` (GNU findutils) — walks the search folder into the index
- `xdg-open` (xdg-utils) — opens results with the MIME association
- `mktemp`, `mv`, `wc`, `head`, `rm` — GNU coreutils, standard on any Linux
desktop
## Install
Install **File Search** from Noctalia's plugin store (*Settings → Plugins*),
then add the widget to a bar from *Settings → Bar*. Plugin options live in
*Settings → Plugins*.
For local development, add your working copy as a path source instead
(`.luau` edits hot-reload):
```sh
noctalia msg plugins source add dev path /path/to/plugins
noctalia msg plugins enable nightwatch75/file-search
```
## Notes
- The index lives in the plugin's private data directory
(`noctalia.pluginDataDir()`, by default
`~/.local/state/noctalia/plugins/data/nightwatch75/file-search/` — honors
`NOCTALIA_STATE_HOME`/`XDG_STATE_HOME`): `index.list` is a plain list of
paths relative to the search folder, and `index.meta` records which folder
and exclusions built it, so both the panel and the launcher rebuild
automatically after a settings change.
- Both files are written to `mktemp`-created private files and renamed into
place, so a rebuild never writes through a symlink planted at the cache
path.
- Names containing a newline are excluded from the index (they would break
the one-record-per-line format), and every record is validated against
the search root before being opened.
- Excluded entries match by folder/file *name* (`find -name`), not by path;
entries containing `/` are skipped and logged.
- With hidden entries off, anything starting with a dot is pruned — both
hidden folders (not descended into) and hidden files.
- Unreadable subtrees are silently skipped (permission errors don't fail the
index).
## License
MIT.
+70
View File
@@ -0,0 +1,70 @@
--!nonstrict
-- file-search — bar widget that toggles the fuzzy search panel.
--
-- The panel (panel.luau) publishes its open state on the shared
-- "file_search_open" state key; the glyph lights up while it is open.
--
-- Click mapping:
-- Left click — open/close the search panel
-- Right click — open the search folder in the file manager
-- Middle click — copy the search folder path to the clipboard
local PANEL_ID = "nightwatch75/file-search:panel"
local open = false
local function shellQuote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local function searchRoot()
local dir = noctalia.getConfig("search_folder")
if dir == nil or dir == "" then
dir = noctalia.getenv("HOME") or "/tmp"
else
dir = noctalia.expandPath(dir)
end
dir = dir:gsub("/+$", "")
if dir == "" then
dir = "/"
end
return dir
end
local function render()
barWidget.setGlyph(noctalia.getConfig("glyph"))
local root = searchRoot()
if open then
barWidget.setGlyphColor("primary")
barWidget.setTooltip(noctalia.tr("tooltip_open", { path = root }))
else
barWidget.setGlyphColor("on_surface")
barWidget.setTooltip(noctalia.tr("tooltip_closed", { path = root }))
end
end
noctalia.state.watch("file_search_open", function(value)
open = value == true
render()
end)
-- Periodic re-render keeps the glyph and tooltip in sync with settings changes.
function update()
render()
end
function onClick()
noctalia.togglePanel(PANEL_ID)
end
function onRightClick()
noctalia.runAsync("xdg-open " .. shellQuote(searchRoot()) .. " >/dev/null 2>&1")
end
function onMiddleClick()
noctalia.copyToClipboard(searchRoot(), "text/plain")
noctalia.notify(noctalia.tr("title"), noctalia.tr("copied_path"))
end
noctalia.setUpdateInterval(1000)
render()
+286
View File
@@ -0,0 +1,286 @@
--!nonstrict
-- file-search — launcher provider: the same fuzzy search with the native
-- keyboard flow of the noctalia launcher (type, arrows, Enter).
--
-- `/fs <text>` fuzzy-matches over the index file the panel builds; when the
-- index is missing it is built here on demand. Activating a result opens it
-- with the system MIME association (xdg-open) — directories open in the
-- file manager. Late async results map back through the query echo of
-- launcher.setResults, so fzf can answer out of band.
local MAX_RESULTS = 9
local searching = false
local indexing = false
local pendingQuery = nil -- latest query typed while a search/index ran
local runQuery
local function tr(key, args)
return noctalia.tr(key, args)
end
local function trim(value)
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function shellQuote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
-- Private per-plugin storage (XDG state); the host creates the directory on
-- every call. nil (with a log line) when no state directory resolves.
local function dataDir()
local dir, err = noctalia.pluginDataDir()
if dir == nil then
noctalia.log("file-search: pluginDataDir failed: " .. tostring(err))
return nil
end
return dir
end
-- Shell header shared by every cache command. The .meta sidecar holds the
-- settings fingerprint the cache was built with, so the panel and the
-- launcher can both tell a stale index apart.
local function cacheSh(dir)
return "CACHE_DIR=" .. shellQuote(dir)
.. '\nCACHE="$CACHE_DIR/index.list"\nMETA="$CACHE_DIR/index.meta"'
end
local function searchRoot()
local dir = noctalia.getConfig("search_folder")
if dir == nil or dir == "" then
dir = noctalia.getenv("HOME") or "/tmp"
else
dir = noctalia.expandPath(dir)
end
dir = dir:gsub("/+$", "")
if dir == "" then
dir = "/"
end
return dir
end
-- Same exclusion set as the panel: names from the setting plus hidden
-- entries unless enabled.
local function excludeNames()
local raw = noctalia.getConfig("exclude_dirs")
if type(raw) ~= "string" then
raw = ""
end
local names = {}
for entry in raw:gmatch("[^,;]+") do
local name = trim(entry)
if name ~= "" and not name:find("/") then
table.insert(names, name)
end
end
if noctalia.getConfig("show_hidden") ~= true then
table.insert(names, ".*")
end
return names
end
-- Must build the same string as the panel's indexKey(): the fingerprint in
-- the .meta sidecar is how the two entries recognize each other's index.
local function indexKey()
return searchRoot() .. "\n" .. table.concat(excludeNames(), "\n")
end
-- The cache on disk is current when its fingerprint matches the settings,
-- whether the panel or the launcher built it.
local function cacheFresh(dir)
return noctalia.readFile(dir .. "/index.meta") == indexKey()
end
-- One cache record, about to be joined to the search root. The cache is a
-- plain user-editable file, so records are untrusted: reject anything that
-- could resolve outside the root.
local function safeRel(rel)
if rel == "" or rel:sub(1, 1) == "/" or rel:find("\n", 1, true) then
return false
end
for part in rel:gmatch("[^/]+") do
if part == ".." then
return false
end
end
return true
end
local function noopRow(titleKey)
return { id = "noop", title = tr(titleKey), glyph = "info-circle" }
end
local function buildIndex(query)
if indexing then
pendingQuery = query
return
end
local dir = dataDir()
if dir == nil then
launcher.setResults(query, { noopRow("err_index") })
return
end
indexing = true
pendingQuery = query
local key = indexKey()
-- Names containing a newline would each forge extra one-per-fragment
-- records (a crafted name can smuggle '..' lines into the index), so
-- they are pruned unconditionally, before the user's exclusions.
local names = { "-name " .. shellQuote("*\n*") }
for _, name in ipairs(excludeNames()) do
table.insert(names, "-name " .. shellQuote(name))
end
local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o "
-- find's own exit status is ignored, so permission errors inside the
-- tree don't fail the build. Cache and fingerprint are written to
-- mktemp-created private files and renamed into place: rename replaces
-- a planted symlink at the destination instead of following it. The
-- host guarantees $CACHE_DIR exists (created by pluginDataDir above).
local cmd = cacheSh(dir)
.. '\nTMP=$(mktemp "$CACHE_DIR/index.list.XXXXXX") || exit 1\n'
.. "find " .. shellQuote(searchRoot()) .. " -mindepth 1 " .. prune
.. "-type d -printf '%P/\\n' -o -printf '%P\\n' > \"$TMP\" 2>/dev/null\n"
.. 'mv -f "$TMP" "$CACHE" || exit 1\n'
.. 'TMPM=$(mktemp "$CACHE_DIR/index.meta.XXXXXX") || exit 1\n'
.. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n'
.. 'mv -f "$TMPM" "$META" || exit 1'
launcher.setResults(query, { noopRow("launcher.indexing") })
local ok = noctalia.runAsync(cmd, function(result)
indexing = false
local queued = pendingQuery
pendingQuery = nil
if result.exitCode == 0 and not result.timedOut then
if indexKey() ~= key then
-- Settings changed while the walk was running: the cache
-- holds paths relative to the old root, and activation
-- would join them to the new one. Rebuild instead of
-- searching stale records (mirrors the panel).
buildIndex(queued or query)
else
runQuery(queued or query)
end
else
launcher.setResults(queued or query, { noopRow("err_index") })
end
end, 180000)
if not ok then
indexing = false
launcher.setResults(query, { noopRow("err_spawn") })
end
end
runQuery = function(query)
if indexing then
pendingQuery = query
return
end
if searching then
pendingQuery = query
return
end
local dir = dataDir()
if dir == nil then
launcher.setResults(query, { noopRow("err_index") })
return
end
searching = true
local text = trim(query)
local cmd
if text == "" then
cmd = cacheSh(dir) .. '\nhead -n ' .. MAX_RESULTS .. ' "$CACHE" 2>/dev/null'
else
cmd = cacheSh(dir) .. "\nfzf --filter=" .. shellQuote(text)
.. ' < "$CACHE" 2>/dev/null | head -n ' .. MAX_RESULTS
end
local ok = noctalia.runAsync(cmd, function(result)
searching = false
local rows = {}
if not result.timedOut then
for line in (result.stdout or ""):gmatch("[^\n]+") do
local isDir = line:sub(-1) == "/"
local rel = line:gsub("/+$", "")
table.insert(rows, {
id = "open:" .. line,
title = rel:match("[^/]+$") or rel,
subtitle = line,
glyph = isDir and "folder" or "file",
})
end
end
if #rows == 0 and text ~= "" then
table.insert(rows, noopRow("launcher.no_results"))
end
if text == "" then
table.insert(rows, {
id = "reindex",
title = tr("launcher.reindex_title"),
subtitle = searchRoot(),
glyph = "refresh",
})
end
launcher.setResults(query, rows)
if pendingQuery ~= nil and pendingQuery ~= query then
local nextQuery = pendingQuery
pendingQuery = nil
runQuery(nextQuery)
end
end, 15000)
if not ok then
searching = false
end
end
function onQuery(query)
if not noctalia.commandExists("fzf") then
launcher.setResults(query, { noopRow("err_no_fzf") })
return
end
local dir = dataDir()
if dir == nil then
launcher.setResults(query, { noopRow("err_index") })
return
end
-- A missing or stale index (no meta, or built with a different root or
-- exclusion set) is rebuilt before searching: stale relative paths
-- joined to a new root could resolve to unrelated files.
if not cacheFresh(dir) then
buildIndex(query)
return
end
runQuery(query)
end
function onActivate(id)
if id == "reindex" then
local dir = dataDir()
if dir == nil then
return
end
-- Drop cache and fingerprint so the next keystroke rebuilds against
-- fresh disk state (a surviving .meta would read as a fresh index).
noctalia.runAsync(cacheSh(dir) .. '\nrm -f "$CACHE" "$META"', function(_result)
noctalia.notify(tr("title"), tr("launcher.reindex_done"))
end)
return
end
local rel = id:match("^open:(.+)$")
if rel == nil then
return
end
if not safeRel(rel) then
noctalia.log("file-search: refusing unsafe index record: " .. rel)
noctalia.notify(tr("title"), tr("err_bad_record"))
return
end
local path = searchRoot()
if path ~= "/" then
path = path .. "/"
end
path = path .. rel:gsub("/+$", "")
noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1")
end
+387
View File
@@ -0,0 +1,387 @@
--!nonstrict
-- file-search — fuzzy search panel, fzf as the matching subsystem.
--
-- On open the search folder is walked once with `find` into a cache file
-- (excluded directory names are pruned, hidden entries too unless enabled);
-- after that every keystroke runs `fzf --filter=<query>` over the cache, so
-- typing stays responsive even on large trees. Results update live; picking
-- one opens it with the system MIME association (xdg-open) — directories
-- open in the file manager. Enter opens the top match.
--
-- The index is rebuilt when the panel opens with changed settings, or on
-- demand via the refresh button. The bar widget mirrors the panel's open
-- state through the shared "file_search_open" state key.
local query = ""
local results = {} -- relative paths; directories keep a trailing "/"
local total = nil -- entries in the index, shown in the footer
local indexing = false
local searching = false
local errMsg = nil
local fzfMissing = false
local inputRev = 0 -- bumped to reseed the query input on open
local haveIndex = false -- the cache on disk matches the current settings
local render
local runSearch
local buildIndex
-- Per-row callbacks need distinct global names; the reconciler dispatches
-- callbacks by name only. getfenv() is the script environment lua_getglobal
-- reads from, so assigning into it defines the callback the host will find.
local env = getfenv()
local function rowCallback(prefix, index, fn)
local name = prefix .. "_" .. index
env[name] = fn
return name
end
local function tr(key, args)
return noctalia.tr(key, args)
end
local function trim(value)
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function shellQuote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
-- Private per-plugin storage (XDG state); the host creates the directory on
-- every call. nil (with a log line) when no state directory resolves.
local function dataDir()
local dir, err = noctalia.pluginDataDir()
if dir == nil then
noctalia.log("file-search: pluginDataDir failed: " .. tostring(err))
return nil
end
return dir
end
-- Shell header shared by every cache command. The .meta sidecar holds the
-- settings fingerprint the cache was built with, so the panel and the
-- launcher can both tell a stale index apart.
local function cacheSh(dir)
return "CACHE_DIR=" .. shellQuote(dir)
.. '\nCACHE="$CACHE_DIR/index.list"\nMETA="$CACHE_DIR/index.meta"'
end
local function searchRoot()
local dir = noctalia.getConfig("search_folder")
if dir == nil or dir == "" then
dir = noctalia.getenv("HOME") or "/tmp"
else
dir = noctalia.expandPath(dir)
end
dir = dir:gsub("/+$", "")
if dir == "" then
dir = "/"
end
return dir
end
local function maxResults()
return math.max(10, math.min(200, tonumber(noctalia.getConfig("max_results")) or 50))
end
-- Excluded directory names from the setting, split on ',' or ';'. Matching
-- is by basename (find -name), so entries containing '/' are skipped and
-- logged. Hidden entries are folded in as an extra '.*' pattern.
local function excludeNames()
local raw = noctalia.getConfig("exclude_dirs")
if type(raw) ~= "string" then
raw = ""
end
local names = {}
for entry in raw:gmatch("[^,;]+") do
local name = trim(entry)
if name:find("/") then
noctalia.log("file-search: ignoring exclude entry with '/': '" .. name .. "'")
elseif name ~= "" then
table.insert(names, name)
end
end
if noctalia.getConfig("show_hidden") ~= true then
table.insert(names, ".*")
end
return names
end
local function indexKey()
return searchRoot() .. "\n" .. table.concat(excludeNames(), "\n")
end
-- The cache on disk is current when its fingerprint matches the settings,
-- whether the panel or the launcher built it.
local function cacheFresh(dir)
return noctalia.readFile(dir .. "/index.meta") == indexKey()
end
-- One cache record, about to be joined to the search root. The cache is a
-- plain user-editable file, so records are untrusted: reject anything that
-- could resolve outside the root.
local function safeRel(rel)
if rel == "" or rel:sub(1, 1) == "/" or rel:find("\n", 1, true) then
return false
end
for part in rel:gmatch("[^/]+") do
if part == ".." then
return false
end
end
return true
end
buildIndex = function()
if fzfMissing or indexing then
return
end
local dir = dataDir()
if dir == nil then
errMsg = tr("err_index")
render()
return
end
indexing = true
errMsg = nil
local key = indexKey()
-- Names containing a newline would each forge extra one-per-fragment
-- records (a crafted name can smuggle '..' lines into the index), so
-- they are pruned unconditionally, before the user's exclusions.
local names = { "-name " .. shellQuote("*\n*") }
for _, name in ipairs(excludeNames()) do
table.insert(names, "-name " .. shellQuote(name))
end
local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o "
-- %P prints paths relative to the root; the trailing '/' marks
-- directories so rows get the right glyph. find's own exit status is
-- ignored, so permission errors inside the tree don't fail the build.
-- Cache and fingerprint are written to mktemp-created private files and
-- renamed into place: rename replaces a planted symlink at the
-- destination instead of following it. The host guarantees $CACHE_DIR
-- exists (created by pluginDataDir above).
local cmd = cacheSh(dir)
.. '\nTMP=$(mktemp "$CACHE_DIR/index.list.XXXXXX") || exit 1\n'
.. "find " .. shellQuote(searchRoot()) .. " -mindepth 1 " .. prune
.. "-type d -printf '%P/\\n' -o -printf '%P\\n' > \"$TMP\" 2>/dev/null\n"
.. 'mv -f "$TMP" "$CACHE" || exit 1\n'
.. 'TMPM=$(mktemp "$CACHE_DIR/index.meta.XXXXXX") || exit 1\n'
.. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n'
.. 'mv -f "$TMPM" "$META" || exit 1\n'
.. 'wc -l < "$CACHE"'
render()
local ok = noctalia.runAsync(cmd, function(result)
indexing = false
if result.exitCode == 0 and not result.timedOut then
haveIndex = true
total = tonumber(trim(result.stdout or "")) or 0
if indexKey() ~= key then
buildIndex() -- settings changed while the walk was running
else
runSearch()
end
else
haveIndex = false
errMsg = tr("err_index")
end
render()
end, 180000)
if not ok then
indexing = false
errMsg = tr("err_spawn")
render()
end
end
runSearch = function()
if fzfMissing or indexing or searching or not haveIndex then
return
end
local dir = dataDir()
if dir == nil then
return
end
searching = true
local q = query
local limit = maxResults()
local cmd
if trim(q) == "" then
cmd = cacheSh(dir) .. '\nhead -n ' .. limit .. ' "$CACHE" 2>/dev/null'
else
-- head closes the pipe early; the pipeline status stays head's (0).
cmd = cacheSh(dir) .. "\nfzf --filter=" .. shellQuote(q)
.. ' < "$CACHE" 2>/dev/null | head -n ' .. limit
end
local ok = noctalia.runAsync(cmd, function(result)
searching = false
if not result.timedOut then
results = {}
for line in (result.stdout or ""):gmatch("[^\n]+") do
table.insert(results, line)
end
end
-- The query moved on while this search ran: chase it. The stale
-- rows rendered below are overwritten as soon as it completes.
if query ~= q then
runSearch()
end
render()
end, 15000)
if not ok then
searching = false
end
end
local function openEntry(rel)
if not safeRel(rel) then
noctalia.log("file-search: refusing unsafe index record: " .. rel)
noctalia.notify(tr("title"), tr("err_bad_record"))
return
end
local path = searchRoot()
if path ~= "/" then
path = path .. "/"
end
path = path .. rel:gsub("/+$", "")
noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1")
panel.close()
end
local function resultRow(rel, index)
local isDir = rel:sub(-1) == "/"
return ui.button({
key = "hit-" .. index,
glyph = isDir and "folder" or "file",
text = rel,
variant = "ghost",
contentAlign = "start",
onClick = rowCallback("openHit", index, function()
openEntry(rel)
end),
})
end
local function statusFooter()
if indexing then
return ui.label({ text = tr("status_indexing", { path = searchRoot() }), fontSize = 11, color = "secondary" })
end
local shown = #results
return ui.label({
text = tr("counts", { shown = shown, total = total or 0 }),
fontSize = 11,
color = "on_surface_variant",
})
end
render = function()
local children = {
ui.row({ gap = 6, align = "center" }, {
ui.label({
text = tr("title"),
fontSize = 16,
fontWeight = "bold",
color = "on_surface",
flexGrow = 1,
}),
ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("tip_refresh"), onClick = "onRefreshIndex" }),
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }),
}),
ui.input({
key = "query-" .. inputRev,
value = query,
focus = true,
placeholder = tr("search_placeholder"),
onChange = "onQueryChanged",
onSubmit = "onOpenFirst",
}),
}
if fzfMissing then
table.insert(children, ui.label({ text = tr("err_no_fzf"), color = "error" }))
elseif errMsg ~= nil then
table.insert(children, ui.label({ text = errMsg, color = "error" }))
else
if #results == 0 and not indexing and trim(query) ~= "" then
table.insert(children, ui.label({
text = tr("no_results", { query = query }),
color = "on_surface_variant",
flexGrow = 1,
}))
else
local rows = {}
for index, rel in ipairs(results) do
table.insert(rows, resultRow(rel, index))
end
table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows))
end
table.insert(children, statusFooter())
end
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children))
end
function onOpen(_context)
fzfMissing = not noctalia.commandExists("fzf")
query = ""
results = {}
inputRev += 1
noctalia.state.set("file_search_open", true)
if not fzfMissing then
local dir = dataDir()
if dir == nil then
errMsg = tr("err_index")
else
haveIndex = cacheFresh(dir)
if not haveIndex then
buildIndex()
else
runSearch()
end
end
end
render()
end
function onClose()
noctalia.state.set("file_search_open", false)
end
function onConfigChanged()
if fzfMissing or indexing then
return
end
local dir = dataDir()
if dir == nil then
return
end
haveIndex = cacheFresh(dir)
if not haveIndex then
buildIndex()
else
runSearch()
end
end
function onQueryChanged(value)
query = value
runSearch()
end
function onOpenFirst(value)
query = value
if results[1] ~= nil then
openEntry(results[1])
end
end
function onRefreshIndex()
haveIndex = false
buildIndex()
end
function onClosePanel()
panel.close()
end
+78
View File
@@ -0,0 +1,78 @@
# File Search — fuzzy file & folder search from the bar, powered by fzf.
# The bar glyph toggles a search panel: the search folder is indexed with
# `find` (honoring the excluded directories), every keystroke is matched
# through `fzf --filter`, and picking a result opens it with the system
# MIME association (xdg-open).
id = "nightwatch75/file-search"
name = "File Search"
version = "0.0.10"
plugin_api = 3
author = "nightwatch75"
license = "MIT"
# The exact commands the plugin spawns (fzf aside, findutils + coreutils +
# xdg-utils on any Linux desktop).
dependencies = ["fzf", "find", "mktemp", "mv", "wc", "head", "rm", "xdg-open"]
tags = ["bar", "launcher", "panel", "utility", "productivity"]
icon = "search"
description = "Search files and folders as you type, fuzzy-matched with fzf; open results with the system MIME association."
# Plugin-level settings: shared by the bar widget (tooltip, folder actions)
# and the panel (index + search).
[[setting]]
key = "search_folder"
type = "folder"
label_key = "settings.search_folder.label"
description_key = "settings.search_folder.description"
[[setting]]
key = "exclude_dirs"
type = "string"
label_key = "settings.exclude_dirs.label"
description_key = "settings.exclude_dirs.description"
default = ".git, node_modules, .cache, .venv"
[[setting]]
key = "show_hidden"
type = "bool"
label_key = "settings.show_hidden.label"
description_key = "settings.show_hidden.description"
default = false
[[setting]]
key = "max_results"
type = "int"
label_key = "settings.max_results.label"
description_key = "settings.max_results.description"
default = 50
min = 10
max = 200
# Keyboard-first flow: the noctalia launcher provides native arrows + Enter
# navigation, which plugin panels cannot receive from the current Luau API.
[[launcher_provider]]
id = "launcher"
entry = "launcher.luau"
prefix = "fs"
glyph = "search"
include_in_global_search = false
[[panel]]
id = "panel"
entry = "panel.luau"
width = 520
height = 480
placement = "attached"
open_near_click = true
[[widget]]
id = "file-search"
entry = "file-search.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
description_key = "settings.glyph.description"
default = "search"
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+44
View File
@@ -0,0 +1,44 @@
{
"copied_path": "Search folder path copied to clipboard",
"counts": "{shown} shown · {total} indexed",
"err_bad_record": "Ignored an invalid index record — rebuild the index",
"err_index": "Failed to index the search folder",
"err_no_fzf": "fzf not found — install fzf to use this plugin",
"err_spawn": "Could not run the search command",
"launcher": {
"indexing": "Indexing files…",
"no_results": "No matches",
"reindex_done": "Search index dropped — it rebuilds on the next search",
"reindex_title": "Rebuild search index"
},
"no_results": "No matches for \"{query}\"",
"search_placeholder": "Type to search files and folders…",
"settings": {
"exclude_dirs": {
"description": "Folder names skipped while indexing, separated by ',' or ';' (matched anywhere in the tree), e.g. '.git, node_modules, .cache'.",
"label": "Excluded folders"
},
"glyph": {
"description": "Icon shown on the bar.",
"label": "Glyph"
},
"max_results": {
"description": "How many matches the panel lists at most.",
"label": "Max results"
},
"search_folder": {
"description": "Root folder the search indexes. Defaults to your home folder when empty.",
"label": "Search folder"
},
"show_hidden": {
"description": "Index files and folders whose name starts with a dot.",
"label": "Include hidden entries"
}
},
"status_indexing": "Indexing {path}…",
"tip_close": "Close",
"tip_refresh": "Rebuild the file index",
"title": "File Search",
"tooltip_closed": "File search — {path}\nClick to search · right-click: folder · middle-click: copy path",
"tooltip_open": "File search open — {path}\nClick to close"
}
+77
View File
@@ -0,0 +1,77 @@
# Home Assistant
Monitor and control your Home Assistant entities from the Noctalia bar and control center. Useful for quickly toggling lights, checking sensor states, and opening a full entity manager panel for richer controls.
## Plugin
| Field | Value |
| --- | --- |
| ID | `pozzoo/hassio` |
| Entries | Widget: `status`; Service: `connection`; Shortcuts: `ha_toggle_1`, `ha_toggle_2`, `ha_toggle_3`, `ha_toggle_4`, `ha_panel`; Panel: `entity_manager` |
| Launcher Prefix | — |
## Requirements
- A running Home Assistant instance with API access enabled
- A Long-lived Access Token (Profile → Security → Long-lived access tokens)
- `xdg-open` available on `PATH` (used to open the Home Assistant URL in your browser)
## Usage
1. Configure the plugin: open **Settings → Plugins → Home Assistant** and set:
| Setting | Description |
| --- | --- |
| Home Assistant URL | The URL to your HA instance, for example `http://homeassistant.local:8123` |
| Long-Lived Access Token | Token created in HA under Profile → Security |
| Quick Toggle 1–4 — Entity ID | (Optional) Entity IDs to assign to each quick-toggle tile (e.g. `light.living_room`) |
2. Add the bar widget: add the **status** widget from the widget picker to show connection state and (optionally) the number of monitored entities. Right-click the widget to open your Home Assistant URL in the default browser.
3. Add control-center tiles: go to **Settings → Control Center** and add any combination of:
- **Home Assistant** (×4) — Quick-toggle tiles. Each corresponds to one of the four entity slots configured in plugin settings.
- **Home Assistant** (panel opener) — Opens the entity manager panel.
4. Open the entity manager panel: use the panel opener tile or the panel IPC command to open the full browser and pin entities for monitoring.
```sh
noctalia msg panel-toggle pozzoo/hassio:entity_manager
```
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `ha_url` | `string` | `""` | Home Assistant base URL used for API requests (include protocol and port if needed). |
| `ha_token` | `string` | `""` | Long-lived access token for Home Assistant. Keep this secret. |
| `shortcut_entity_1` | `string` | `""` | Entity ID used by Quick Toggle 1 (e.g. `light.kitchen`). |
| `shortcut_entity_2` | `string` | `""` | Entity ID used by Quick Toggle 2. |
| `shortcut_entity_3` | `string` | `""` | Entity ID used by Quick Toggle 3. |
| `shortcut_entity_4` | `string` | `""` | Entity ID used by Quick Toggle 4. |
| `show_entity_count` | `bool` | `false` | Show the number of monitored entities next to the connection status in the `status` bar widget. |
## IPC
- Open the panel:
```sh
noctalia msg panel-toggle pozzoo/hassio:entity_manager
```
- Force a refresh of the connection and entity states:
```sh
noctalia msg plugin pozzoo/hassio:status focused refresh
```
Use `all` in place of `focused` to target every bar instance. This triggers the same refresh as sending the `refresh` command internally, and shows a notification when it starts.
Notes: the plugin forwards Home Assistant state updates internally and uses Noctalia's state routing to update widgets and shortcuts.
## Notes
- The plugin maintains a live SSE connection to Home Assistant to receive real-time state changes. Noctalia's native HTTP streaming API is used for this connection; all other requests (fetching states, toggling entities, browsing) go through Noctalia's native HTTP API.
- The pinned entity list is saved to `managed_entities.json` in the plugin's persistent data directory (`noctalia.pluginDataDir()`), so it survives plugin updates.
- To authenticate the SSE connection without exposing the access token on the command line, the plugin uses Noctalia's native streaming request headers.
- The plugin issues HTTP requests to your HA instance; do not install untrusted plugins if you do not want them to access your network or tokens.
- If authentication fails, generate a new long-lived access token in Home Assistant and paste it into plugin settings.
+634
View File
@@ -0,0 +1,634 @@
--!nonstrict
local haUrl = noctalia.getConfig("ha_url")
local haToken = noctalia.getConfig("ha_token")
local entityStates = {}
local status = "unconfigured"
local expandedEntityId = nil
local entitySlots = {}
local sliderDraft = {}
local sliderDirty = {}
local pendingToggle = {}
local pendingSince = {} -- entity_id -> os.clock() when toggle was requested
local PENDING_TIMEOUT_SECONDS = 8
local view = "list" -- "list" | "browser"
local allEntities = {}
local browserSlots = {}
local browserLoading = false
local searchText = ""
local monitoredEntities = {} -- current monitored list (owned here, saved to file)
local panelOpenCount = 0
local function tr(key, subst)
return noctalia.tr("panel." .. key, subst)
end
local function getStatusText()
if status == "connected" then
return noctalia.tr("widget.status_connected")
elseif status == "connecting" then
return noctalia.tr("widget.status_connecting")
elseif status == "disconnected" then
return noctalia.tr("widget.status_disconnected")
elseif status == "auth_failed" then
return noctalia.tr("widget.status_auth_failed")
else
return noctalia.tr("widget.status_unconfigured")
end
end
local function getCleanToken()
if type(haToken) ~= "string" then return "" end
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
end
local function haPost(path, body)
if not haUrl or haUrl == "" then return end
local cleanToken = getCleanToken()
if cleanToken == "" then return end
local encodedBody = noctalia.json.encode(body)
noctalia.http({
url = haUrl .. path,
method = "POST",
headers = {
"Authorization: Bearer " .. cleanToken,
"Content-Type: application/json",
},
body = encodedBody,
}, function(response: HttpResponse)end)
end
local function callService(domain, service, entityId)
haPost("/api/services/" .. domain .. "/" .. service, { entity_id = entityId })
end
local MANAGED_ENTITIES_FILE = "managed_entities.json"
local function managedEntitiesPath()
local dataDir, dirErr = noctalia.pluginDataDir()
if not dataDir then
noctalia.log("Cannot resolve plugin data dir: " .. (dirErr or "unknown"))
return nil
end
return dataDir .. "/" .. MANAGED_ENTITIES_FILE
end
local function loadMonitoredEntities()
local path = managedEntitiesPath()
local data = path and noctalia.readFile(path)
if data and data ~= "" then
local parsed = noctalia.json.decode(data)
if type(parsed) == "table" then
monitoredEntities = parsed
return
end
end
monitoredEntities = {}
end
local function saveMonitoredEntities()
local path = managedEntitiesPath()
if not path then
noctalia.notifyError(noctalia.tr("shortcut.title"), tr("save_failed_no_data_dir"))
return
end
local encodedEntities = noctalia.json.encode(monitoredEntities)
local ok, writeErr = noctalia.writeFile(path, encodedEntities)
if not ok then
noctalia.log("Failed to save managed entities: " .. (writeErr or "unknown"))
noctalia.notifyError(noctalia.tr("shortcut.title"), tr("save_failed"))
return
end
noctalia.state.set("entities_override", monitoredEntities)
noctalia.state.set("command", "reload_entities")
end
local function isPinned(entityId)
for _, eid in ipairs(monitoredEntities) do
if eid == entityId then return true end
end
return false
end
local function toggleBrowserPin(entityId)
if not entityId then return end
local newList, found = {}, false
for _, eid in ipairs(monitoredEntities) do
if eid == entityId then found = true
else table.insert(newList, eid) end
end
if not found then table.insert(newList, entityId) end
monitoredEntities = newList
saveMonitoredEntities()
render()
end
local function fetchAllEntities()
if browserLoading then return end
local cleanToken = getCleanToken()
if not haUrl or haUrl == "" or cleanToken == "" then
allEntities = {}
browserLoading = false
render()
return
end
browserLoading = true
render()
noctalia.http({
url = haUrl .. "/api/states",
headers = {
"Authorization: Bearer " .. cleanToken,
}
}, function(response: HttpResponse)
browserLoading = false
if response and response.body and response.body ~= "" then
local states = noctalia.json.decode(response.body)
if type(states) == "table" then
allEntities = {}
for _, s in ipairs(states) do
local attrs = s.attributes or {}
table.insert(allEntities, {
entity_id = s.entity_id,
friendly_name = attrs.friendly_name or s.entity_id,
domain = s.entity_id:match("^([^.]+)"),
})
end
table.sort(allEntities, function(a, b) return a.entity_id < b.entity_id end)
end
end
render()
end)
end
local function initDraft(entityId)
if sliderDraft[entityId] then return end
local e = entityStates[entityId]
local ct = e and e.color_temp or -1
local ctKelvin
if ct > 500 then ctKelvin = math.floor(ct + 0.5)
elseif ct > 0 then ctKelvin = math.floor(1000000 / ct + 0.5)
else ctKelvin = 4000 end
sliderDraft[entityId] = {
brightness = e and (e.brightness > 0 and e.brightness or 255) or 255,
color_temp = ctKelvin,
hue = e and (e.hue or 0) or 0,
}
end
local function markDirty(key)
if not expandedEntityId then return end
if not sliderDirty[expandedEntityId] then sliderDirty[expandedEntityId] = {} end
sliderDirty[expandedEntityId][key] = true
end
local function clearPending(entityId)
pendingToggle[entityId] = nil
pendingSince[entityId] = nil
end
local function isPendingTimedOut(entityId, now)
local startedAt = pendingSince[entityId]
if not startedAt then return false end
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
end
local function reconcilePending(now)
local changed = false
for entityId, expected in pairs(pendingToggle) do
local real = entityStates[entityId]
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
clearPending(entityId)
changed = true
end
end
return changed
end
-- _actN/_expN are per-slot callback targets referenced by index from the rendered rows below.
-- In list view they act on the entity in that row's slot; in browser view _actN toggles its pin.
local function actSlot(i)
if view == "browser" then
toggleBrowserPin(browserSlots[i])
return
end
local eid = entitySlots[i]
if not eid then return end
local entity = entityStates[eid]
if not entity then return end
local d = entity.domain
if d == "script" then callService("script", "turn_on", eid)
elseif d == "automation" then callService("automation", "trigger", eid)
else
local newState = entity.state == "on" and "off" or "on"
pendingToggle[eid] = newState
pendingSince[eid] = os.clock()
callService(d, "toggle", eid)
render()
end
end
local function expSlot(i)
local eid = entitySlots[i]
if not eid then return end
if expandedEntityId == eid then
expandedEntityId = nil
else
sliderDraft[eid] = nil
sliderDirty[eid] = nil
expandedEntityId = eid
end
render()
end
function _act1() actSlot(1) end; function _exp1() expSlot(1) end
function _act2() actSlot(2) end; function _exp2() expSlot(2) end
function _act3() actSlot(3) end; function _exp3() expSlot(3) end
function _act4() actSlot(4) end; function _exp4() expSlot(4) end
function _act5() actSlot(5) end; function _exp5() expSlot(5) end
function _act6() actSlot(6) end; function _exp6() expSlot(6) end
function _act7() actSlot(7) end; function _exp7() expSlot(7) end
function _act8() actSlot(8) end; function _exp8() expSlot(8) end
function _act9() actSlot(9) end; function _exp9() expSlot(9) end
function _act10() actSlot(10) end; function _exp10() expSlot(10) end
function _act11() actSlot(11) end; function _exp11() expSlot(11) end
function _act12() actSlot(12) end; function _exp12() expSlot(12) end
function _act13() actSlot(13) end; function _exp13() expSlot(13) end
function _act14() actSlot(14) end; function _exp14() expSlot(14) end
function _act15() actSlot(15) end; function _exp15() expSlot(15) end
function _act16() actSlot(16) end; function _exp16() expSlot(16) end
function _act17() actSlot(17) end; function _exp17() expSlot(17) end
function _act18() actSlot(18) end; function _exp18() expSlot(18) end
function _act19() actSlot(19) end; function _exp19() expSlot(19) end
function _act20() actSlot(20) end; function _exp20() expSlot(20) end
function _act21() actSlot(21) end; function _exp21() expSlot(21) end
function _act22() actSlot(22) end; function _exp22() expSlot(22) end
function _act23() actSlot(23) end; function _exp23() expSlot(23) end
function _act24() actSlot(24) end; function _exp24() expSlot(24) end
function _act25() actSlot(25) end; function _exp25() expSlot(25) end
function _act26() actSlot(26) end; function _exp26() expSlot(26) end
function _act27() actSlot(27) end; function _exp27() expSlot(27) end
function _act28() actSlot(28) end; function _exp28() expSlot(28) end
function _act29() actSlot(29) end; function _exp29() expSlot(29) end
function _act30() actSlot(30) end; function _exp30() expSlot(30) end
function onBrightness(value)
if not expandedEntityId then return end
initDraft(expandedEntityId)
sliderDraft[expandedEntityId].brightness = math.floor((tonumber(value) or 255) + 0.5)
markDirty("brightness")
render()
end
function onColorTemp(value)
if not expandedEntityId then return end
initDraft(expandedEntityId)
sliderDraft[expandedEntityId].color_temp = math.floor((tonumber(value) or 4000) + 0.5)
markDirty("color_temp")
render()
end
function onHue(value)
if not expandedEntityId then return end
initDraft(expandedEntityId)
sliderDraft[expandedEntityId].hue = math.floor((tonumber(value) or 0) + 0.5)
markDirty("hue")
render()
end
function onApplyLight()
if not expandedEntityId then return end
local draft = sliderDraft[expandedEntityId]
local dirty = sliderDirty[expandedEntityId] or {}
local entity = entityStates[expandedEntityId]
if not draft or not entity then return end
local data = { entity_id = expandedEntityId }
if dirty.hue and entity.supports_rgb then
data.hs_color = { draft.hue, 100 }
if dirty.brightness and entity.supports_brightness then
data.brightness = draft.brightness
end
else
if dirty.brightness and entity.supports_brightness then
data.brightness = draft.brightness
end
if dirty.color_temp and entity.supports_color_temp then
data.color_temp_kelvin = draft.color_temp
end
end
if dirty.brightness or dirty.color_temp or dirty.hue then
haPost("/api/services/light/turn_on", data)
sliderDirty[expandedEntityId] = {}
end
end
function onNoop() end
function onOpenBrowser()
view = "browser"
searchText = ""
fetchAllEntities()
end
function onBackToList()
view = "list"
allEntities = {}
searchText = ""
render()
end
function onSearchChange(value)
searchText = value or ""
render()
end
local function isControllable(domain)
return domain == "light" or domain == "switch" or domain == "input_boolean"
or domain == "fan" or domain == "cover" or domain == "lock"
end
local function isSensor(domain)
return domain == "sensor" or domain == "binary_sensor" or domain == "weather" or domain == "number"
end
local function isAutomation(domain)
return domain == "automation" or domain == "script"
end
local function domainGlyph(domain)
local g = {
light = "bulb", switch = "toggle-right", input_boolean = "toggle-right",
sensor = "chart-line", binary_sensor = "activity", climate = "temperature",
cover = "door", fan = "wind", lock = "lock", media_player = "device-speaker",
weather = "cloud", automation = "robot", script = "player-play",
}
return g[domain] or "smart-home"
end
local function stateGlyph(domain, isOn)
if domain == "light" then return isOn and "bulb" or "bulb-off"
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
elseif domain == "fan" then return isOn and "wind" or "wind-off"
elseif domain == "lock" then return isOn and "lock" or "lock-open"
elseif domain == "cover" then return isOn and "door-open" or "door"
end
return domainGlyph(domain)
end
local function entityStateLabel(entity)
if isSensor(entity.domain) then
return entity.state .. (entity.unit ~= "" and " " .. entity.unit or "")
end
if entity.domain == "light" and entity.state == "on" and entity.brightness >= 0 then
local pct = math.floor(entity.brightness / 255 * 100 + 0.5)
return tr("state_on_brightness", { percent = pct })
end
return entity.state
end
local function buildExpandedSection(entity)
local eid = entity.entity_id
initDraft(eid)
local draft = sliderDraft[eid]
local rows = {}
if entity.supports_brightness then
local bv = draft.brightness
table.insert(rows, ui.row({ align = "center", gap = 8 }, {
ui.label({ text = tr("brightness"), color = "on_surface_variant" }),
ui.slider({ min = 1, max = 255, step = 1, value = bv, onChange = "onBrightness", flexGrow = 1 }),
ui.label({ text = math.floor(bv / 255 * 100 + 0.5) .. "%" }),
}))
end
if entity.supports_color_temp then
local ctk = draft.color_temp
table.insert(rows, ui.row({ align = "center", gap = 8 }, {
ui.label({ text = tr("color_temp"), color = "on_surface_variant" }),
ui.slider({ min = 2000, max = 6500, step = 50, value = ctk, onChange = "onColorTemp", flexGrow = 1 }),
ui.label({ text = math.floor(ctk) .. "K" }),
}))
end
if entity.supports_rgb then
local hv = draft.hue
table.insert(rows, ui.row({ align = "center", gap = 8 }, {
ui.label({ text = tr("hue"), color = "on_surface_variant" }),
ui.slider({ min = 0, max = 360, step = 1, value = hv, onChange = "onHue", flexGrow = 1 }),
ui.label({ text = math.floor(hv) .. "°" }),
}))
end
if #rows == 0 then return nil end
table.insert(rows, ui.row({ justify = "end" }, {
ui.button({ glyph = "check", onClick = "onApplyLight" }),
}))
return ui.column({ gap = 8 }, rows)
end
local function buildEntityCard(entity, i)
local pending = pendingToggle[entity.entity_id]
local isOn = (pending or entity.state) == "on"
local domain = entity.domain
local canExpand = domain == "light"
and (entity.supports_brightness or entity.supports_color_temp or entity.supports_rgb)
local isExpanded = expandedEntityId == entity.entity_id
local expandBtn
if canExpand then
expandBtn = ui.button({
glyph = isExpanded and "chevron-up" or "chevron-down",
onClick = "_exp" .. i,
})
end
local isPending = pending ~= nil
local iconClick = isPending and "onNoop"
or (isControllable(domain) or isAutomation(domain)) and "_act" .. i or "onNoop"
local iconGlyph = isPending and "loader"
or isAutomation(domain) and "player-play" or stateGlyph(domain, isOn)
local rowChildren = {
ui.button({ glyph = iconGlyph, onClick = iconClick }),
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = entity.friendly_name or entity.entity_id, fontWeight = "medium" }),
ui.label({ text = entityStateLabel(entity), color = "on_surface_variant", fontSize = 12 }),
}),
}
if expandBtn then table.insert(rowChildren, expandBtn) end
local cardChildren = { ui.row({ align = "center", gap = 8 }, rowChildren) }
if isExpanded then
local expanded = buildExpandedSection(entity)
if expanded then table.insert(cardChildren, expanded) end
end
return ui.column({ gap = 8 }, cardChildren)
end
-- Capped to 30: matches the number of _actN/_expN slot callbacks defined above.
local MAX_SLOTS = 30
local function getOrderedIds()
local ids, seen = {}, {}
for _, eid in ipairs(monitoredEntities) do
if entityStates[eid] then
table.insert(ids, eid)
seen[eid] = true
if #ids >= MAX_SLOTS then return ids end
end
end
for eid in pairs(entityStates) do
if not seen[eid] then
table.insert(ids, eid)
if #ids >= MAX_SLOTS then return ids end
end
end
return ids
end
local function buildListBody(ids)
if not haUrl or haUrl == "" or getCleanToken() == "" then
return ui.label({ text = tr("not_configured"), color = "on_surface_variant" })
end
if status == "auth_failed" then
return ui.label({ text = tr("auth_failed"), color = "error" })
end
if status == "connecting" then
return ui.label({ text = tr("connecting"), color = "on_surface_variant" })
end
if status == "disconnected" then
return ui.label({ text = tr("disconnected_reconnecting"), color = "error" })
end
if #ids == 0 then
return ui.label({
text = status == "connected"
and tr("no_entities_monitored")
or tr("status_fallback", { status = status }),
color = "on_surface_variant",
})
end
local cards = {}
for i, eid in ipairs(ids) do
table.insert(cards, buildEntityCard(entityStates[eid], i))
end
return ui.scroll({ flexGrow = 1, gap = 8 }, cards)
end
local function buildBrowserBody()
if browserLoading then
return ui.label({ text = tr("loading_entities"), color = "on_surface_variant" })
end
if #allEntities == 0 then
return ui.label({ text = tr("no_entities_found"), color = "on_surface_variant" })
end
local q = searchText:lower()
local filtered = {}
for _, e in ipairs(allEntities) do
if q == ""
or e.entity_id:lower():find(q, 1, true)
or e.friendly_name:lower():find(q, 1, true)
then
table.insert(filtered, e)
if #filtered >= 30 then break end
end
end
if #filtered == 0 then
return ui.label({ text = tr("no_entities_match"), color = "on_surface_variant" })
end
browserSlots = {}
local rows = {}
for i, e in ipairs(filtered) do
browserSlots[i] = e.entity_id
local pinned = isPinned(e.entity_id)
table.insert(rows, ui.row({ align = "center", gap = 8 }, {
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = e.friendly_name, fontWeight = "medium" }),
ui.label({ text = e.entity_id, color = "on_surface_variant", fontSize = 12 }),
}),
ui.button({ glyph = pinned and "pin-filled" or "pin", onClick = "_act" .. i }),
}))
end
return ui.scroll({ flexGrow = 1, gap = 4 }, rows)
end
function render()
reconcilePending(os.clock())
if view == "browser" then
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.row({ align = "center", gap = 8 }, {
ui.button({ glyph = "arrow-left", onClick = "onBackToList" }),
ui.label({ text = tr("manage_entities_title"), fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1 }),
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
}),
ui.input({ key = "entity_search", placeholder = tr("search_placeholder"), onChange = "onSearchChange" }),
buildBrowserBody(),
}))
else
local ids = getOrderedIds()
entitySlots = {}
for i, eid in ipairs(ids) do entitySlots[i] = eid end
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
ui.row({ align = "center", gap = 8 }, {
ui.label({ text = noctalia.tr("shortcut.title"), fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1 }),
ui.label({ text = getStatusText(), color = status == "connected" and "primary" or "on_surface_variant" }),
ui.button({ glyph = "list-search", onClick = "onOpenBrowser" }),
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
}),
buildListBody(ids),
}))
end
end
function onOpen(_context)
status = noctalia.state.get("connection_status") or "unconfigured"
entityStates = noctalia.state.get("entities") or {}
loadMonitoredEntities()
panelOpenCount = panelOpenCount + 1
panel.setWantsSecondTicks(true)
noctalia.state.set("panel_open_signal", panelOpenCount)
render()
end
function onClose()
panel.setWantsSecondTicks(false)
end
function update()
if reconcilePending(os.clock()) then
render()
end
end
function onCloseClicked()
panel.close()
end
noctalia.state.watch("entities", function(value)
entityStates = value or {}
reconcilePending(os.clock())
render()
end)
noctalia.state.watch("connection_status", function(value)
status = value or "unconfigured"
render()
end)
+95
View File
@@ -0,0 +1,95 @@
id = "pozzoo/hassio"
name = "Home Assistant"
version = "2.0.0"
plugin_api = 4
author = "Pozzoo"
license = "MIT"
tags = ["bar", "panel", "service", "shortcut", "network", "indicator"]
icon = "smart-home"
dependencies = ["xdg-open"]
description = "Monitor and control Home Assistant entities from the bar. Displays entity states and provides quick toggles."
[[setting]]
key = "ha_url"
type = "string"
label_key = "settings.url.label"
description_key = "settings.url.description"
default = ""
[[setting]]
key = "ha_token"
type = "string"
label_key = "settings.token.label"
description_key = "settings.token.description"
default = ""
[[setting]]
key = "shortcut_entity_1"
type = "string"
label_key = "settings.shortcut_entity_1.label"
description_key = "settings.shortcut_entity.description"
default = ""
[[setting]]
key = "shortcut_entity_2"
type = "string"
label_key = "settings.shortcut_entity_2.label"
description_key = "settings.shortcut_entity.description"
default = ""
[[setting]]
key = "shortcut_entity_3"
type = "string"
label_key = "settings.shortcut_entity_3.label"
description_key = "settings.shortcut_entity.description"
default = ""
[[setting]]
key = "shortcut_entity_4"
type = "string"
label_key = "settings.shortcut_entity_4.label"
description_key = "settings.shortcut_entity.description"
default = ""
[[widget]]
id = "status"
entry = "widget.luau"
[[widget.setting]]
key = "show_entity_count"
type = "bool"
label_key = "settings.show_entity_count.label"
default = false
# Native streaming background service maintaining a live connection to HA
[[service]]
id = "connection"
entry = "service_sse.luau"
[[shortcut]]
id = "ha_toggle_1"
entry = "shortcut.luau"
[[shortcut]]
id = "ha_toggle_2"
entry = "shortcut_2.luau"
[[shortcut]]
id = "ha_toggle_3"
entry = "shortcut_3.luau"
[[shortcut]]
id = "ha_toggle_4"
entry = "shortcut_4.luau"
[[shortcut]]
id = "ha_panel"
entry = "shortcut_panel.luau"
[[panel]]
id = "entity_manager"
entry = "panel.luau"
width = 450
height = 560
placement = "floating"
position = "center"
+428
View File
@@ -0,0 +1,428 @@
--!nonstrict
local haUrl = noctalia.getConfig("ha_url")
local haToken = noctalia.getConfig("ha_token")
local MANAGED_ENTITIES_FILE = "managed_entities.json"
local function loadManagedEntities()
local dataDir, dirErr = noctalia.pluginDataDir()
if not dataDir then
noctalia.log("Cannot resolve plugin data dir: " .. (dirErr or "unknown"))
return {}
end
local data = noctalia.readFile(dataDir .. "/" .. MANAGED_ENTITIES_FILE)
if data and data ~= "" then
local parsed = noctalia.json.decode(data)
if type(parsed) == "table" then return parsed end
end
return {}
end
local entities = loadManagedEntities()
local status = "unconfigured"
local entityStates = {}
local sseActive = false
local streamHandle = nil
local MAX_RECONNECT_RETRIES = 10
local CONNECTED_POLL_INTERVAL_MS = 30000
local RECONNECT_POLL_INTERVAL_MS = 1000
local retryCount = 0
local maxRetriesNotified = false
local suspended = false
local connectionRequestInFlight = false
local function getCleanToken()
if type(haToken) ~= "string" then return "" end
return noctalia.string.trim(haToken)
end
local function syncUpdateInterval()
if status == "connected" and sseActive then
noctalia.setUpdateInterval(CONNECTED_POLL_INTERVAL_MS)
else
noctalia.setUpdateInterval(RECONNECT_POLL_INTERVAL_MS)
end
end
local function isConfigured()
return haUrl and haUrl ~= "" and getCleanToken() ~= ""
end
local function haRequest(endpoint, method, body, callback)
if not isConfigured() then return false end
local url = haUrl .. endpoint
local cleanToken = getCleanToken()
local jsonBody
if body then
jsonBody = noctalia.json.encode(body)
end
local launched = noctalia.http({
url = url,
method = method,
body = jsonBody,
headers = {
"Authorization: Bearer " .. cleanToken,
"Content-Type: application/json",
}
}, function(response: HttpResponse)
if not response then
callback({ ok = false, status = 0, body = "No response" })
return
end
callback({
ok = response.status >= 200 and response.status < 300,
status = response.status,
body = response.body
})
end)
return launched
end
local function supportsColorMode(modes, targets)
if type(modes) ~= "table" then return false end
for _, mode in ipairs(modes) do
for _, target in ipairs(targets) do
if mode == target then return true end
end
end
return false
end
local function processEntity(state)
if not state then return nil end
local attrs = state.attributes or {}
local modes = attrs.supported_color_modes or {}
-- stored as mireds for the panel slider
local colorTemp = -1
if attrs.color_temp_kelvin and attrs.color_temp_kelvin > 0 then
colorTemp = math.floor(1000000 / attrs.color_temp_kelvin + 0.5)
elseif attrs.color_temp and attrs.color_temp > 0 then
colorTemp = attrs.color_temp
end
local currentColor = ""
if type(attrs.rgb_color) == "table" and #attrs.rgb_color == 3 then
currentColor = attrs.rgb_color[1] .. "," .. attrs.rgb_color[2] .. "," .. attrs.rgb_color[3]
end
return {
entity_id = state.entity_id,
state = state.state,
friendly_name = attrs.friendly_name or state.entity_id,
domain = state.entity_id:match("^([^.]+)"),
unit = attrs.unit_of_measurement or "",
brightness = attrs.brightness or -1,
color_temp = colorTemp,
hue = (type(attrs.hs_color) == "table") and (attrs.hs_color[1] or 0) or 0,
current_color = currentColor,
supports_brightness = supportsColorMode(modes, {"brightness","color_temp","hs","xy","rgb","rgbw","rgbww"}),
supports_color_temp = supportsColorMode(modes, {"color_temp"}),
supports_rgb = supportsColorMode(modes, {"hs","xy","rgb","rgbw","rgbww"}),
}
end
local function updateEntity(entityId, newState)
if #entities > 0 then
local found = false
for _, id in ipairs(entities) do
if id == entityId then
found = true
break
end
end
if not found then return end
end
local processed = processEntity(newState)
if processed then
entityStates[entityId] = processed
noctalia.state.set("entities", entityStates)
local count = 0
for _ in pairs(entityStates) do
count = count + 1
end
noctalia.state.set("entity_count", count)
end
end
-- HA embeds event_type inside the data JSON rather than a separate SSE "event:" field
local currentEvent = {
data = "",
event = "",
}
local function tryDispatch()
if not currentEvent.data then return end
local parsed = noctalia.json.decode(currentEvent.data)
if not parsed then return end
local evType = currentEvent.event or parsed.event_type
if evType ~= "state_changed" then return end
local inner = parsed.data or {}
if inner.entity_id and inner.new_state then
updateEntity(inner.entity_id, inner.new_state)
end
end
local function processSSELine(line)
if line == "" then
tryDispatch()
currentEvent = {}
elseif line:match("^event:") then
if currentEvent.data then tryDispatch() end
currentEvent = { event = line:match("^event:%s*(.+)") or "" }
elseif line:match("^data:") then
local data = line:match("^data:%s*(.+)") or ""
local parsed = noctalia.json.decode(data)
if parsed then
local evType = currentEvent.event or parsed.event_type
if evType == "state_changed" then
local inner = parsed.data or {}
if inner.entity_id and inner.new_state then
updateEntity(inner.entity_id, inner.new_state)
currentEvent = {}
return
end
end
end
if currentEvent.data and currentEvent.data ~= "" then
currentEvent.data = currentEvent.data .. "\n" .. data
else
currentEvent.data = data
end
end
end
local function fetchInitialStates()
if not isConfigured() or suspended or connectionRequestInFlight then return end
connectionRequestInFlight = true
local launched = haRequest("/api/states", "GET", nil, function(res)
connectionRequestInFlight = false
if not res.body or res.body == "" then
noctalia.log("Empty response body from /api/states")
status = "disconnected"
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return
end
if not res.ok or res.status == 401 or res.body:match("^401") or res.body:match("Unauthorized") then
status = "auth_failed"
noctalia.log("Authentication failed - check your token")
noctalia.notifyError(noctalia.tr("notifications.auth_failed"))
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return
end
if not res.ok or (res.status and res.status >= 400) then
status = "disconnected"
noctalia.log("HTTP request failed: status " .. (res.status or "unknown"))
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return
end
local allStates, err = noctalia.json.decode(res.body)
if not allStates then
noctalia.log("Failed to parse states: " .. (err or "unknown error"))
status = "disconnected"
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return
end
status = "connected"
noctalia.state.set("connection_status", status)
retryCount = 0
maxRetriesNotified = false
suspended = false
entityStates = {}
local count = 0
for _, state in ipairs(allStates) do
if #entities == 0 then break end
for _, entityId in ipairs(entities) do
if state.entity_id == entityId then
entityStates[entityId] = processEntity(state)
count = count + 1
break
end
end
end
noctalia.state.set("entities", entityStates)
noctalia.state.set("entity_count", count)
if status == "connected" then
if not startSSEStream() then
status = "disconnected"
noctalia.state.set("connection_status", status)
syncUpdateInterval()
else
syncUpdateInterval()
end
end
end)
if not launched then
connectionRequestInFlight = false
status = "disconnected"
noctalia.state.set("connection_status", status)
syncUpdateInterval()
end
end
function startSSEStream()
if suspended then return false end
if sseActive then return true end
local cleanToken = getCleanToken()
if cleanToken == "" then
return false
end
local url = haUrl .. "/api/stream?restrict=state_changed"
noctalia.log("Starting SSE stream")
currentEvent = { data = "", event = "" }
local handle
handle = noctalia.httpStream({
url = url,
headers = {
"Accept: text/event-stream",
"Authorization: Bearer " .. cleanToken,
},
}, function(line)
processSSELine(line)
end, function(result)
if streamHandle ~= handle then return end
streamHandle = nil
sseActive = false
currentEvent = {}
if suspended then return end
if result and result.ok and result.status == 401 then
status = "auth_failed"
noctalia.log("Authentication failed - check your token")
noctalia.notifyError(noctalia.tr("notifications.auth_failed"))
noctalia.state.set("connection_status", status)
syncUpdateInterval()
return
end
status = "disconnected"
noctalia.log("SSE stream closed")
noctalia.state.set("connection_status", status)
syncUpdateInterval()
end)
if not handle then
noctalia.log("Failed to start SSE stream")
return false
end
streamHandle = handle
sseActive = true
syncUpdateInterval()
return true
end
noctalia.state.watch("command", function(cmd)
if cmd == "refresh" then
-- Just refetch a fresh snapshot over HTTP. Do NOT touch sseActive here:
-- the running stream (if any) is untouched by this, and forcing the
-- flag false would bypass startSSEStream()'s duplicate guard and spawn
-- a second stream on top of one that's still alive.
fetchInitialStates()
elseif cmd == "reload_entities" then
local override = noctalia.state.get("entities_override")
if type(override) == "table" then
entities = override
else
entities = loadManagedEntities()
end
-- Entity filtering happens client-side in updateEntity(), so the
-- existing stream keeps working for the new entity list as-is.
-- The initial snapshot needs refetching, though, to account for any
-- entities that were removed from the filter. See the note above for why
-- sseActive must not be forced here.
fetchInitialStates()
end
end)
noctalia.state.watch("panel_open_signal", function(value)
if value then
retryCount = 0
maxRetriesNotified = false
suspended = false
end
end)
function update()
syncUpdateInterval()
if not isConfigured() then
status = "unconfigured"
noctalia.state.set("connection_status", status)
sseActive = false
return
end
if suspended then
return
end
if status == "disconnected" then
retryCount = retryCount + 1
if retryCount >= MAX_RECONNECT_RETRIES then
if not maxRetriesNotified then
maxRetriesNotified = true
noctalia.notifyError(noctalia.tr("notifications.reconnect_failed", { count = MAX_RECONNECT_RETRIES }))
end
suspended = true
status = "disconnected"
noctalia.state.set("connection_status", status)
return
end
status = "connecting"
noctalia.state.set("connection_status", status)
fetchInitialStates()
end
end
function onExit()
if streamHandle then
streamHandle.stop()
streamHandle = nil
end
sseActive = false
end
noctalia.state.set("connection_status", status)
noctalia.state.set("entity_count", 0)
noctalia.state.set("entities", {})
if isConfigured() then
status = "connecting"
noctalia.state.set("connection_status", status)
fetchInitialStates()
end
+144
View File
@@ -0,0 +1,144 @@
--!nonstrict
local haUrl = noctalia.getConfig("ha_url")
local haToken = noctalia.getConfig("ha_token")
local configuredEntityId = noctalia.getConfig("shortcut_entity_1") or ""
local SLOT_LABEL = noctalia.tr("shortcut.label_1")
local entityStates = {}
local pendingToggle = {}
local pendingSince = {}
local pendingTimerToken = {}
local pendingTimerSeq = 0
local PENDING_TIMEOUT_SECONDS = 8
local render
local function getCleanToken()
if type(haToken) ~= "string" then return "" end
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
end
local function stateGlyph(domain, isOn)
if domain == "light" then return isOn and "bulb" or "bulb-off"
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
elseif domain == "fan" then return isOn and "wind" or "wind-off"
elseif domain == "lock" then return isOn and "lock" or "lock-open"
elseif domain == "cover" then return isOn and "door-open" or "door"
end
return "smart-home"
end
local function getEntity()
if configuredEntityId == "" then return nil end
return entityStates[configuredEntityId]
end
local function clearPending(entityId)
pendingToggle[entityId] = nil
pendingSince[entityId] = nil
pendingTimerToken[entityId] = nil
end
local function isPendingTimedOut(entityId, now)
local startedAt = pendingSince[entityId]
if not startedAt then return false end
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
end
local function reconcilePending(now)
for entityId, expected in pairs(pendingToggle) do
local real = entityStates[entityId]
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
clearPending(entityId)
end
end
end
local function schedulePendingTimeout(entityId)
pendingTimerSeq = pendingTimerSeq + 1
local token = pendingTimerSeq
pendingSince[entityId] = os.clock()
pendingTimerToken[entityId] = token
noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result)
if pendingTimerToken[entityId] ~= token then return end
if pendingToggle[entityId] and isPendingTimedOut(entityId) then
clearPending(entityId)
render()
end
end)
end
render = function()
reconcilePending(os.clock())
local entity = getEntity()
if not entity then
shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL)
shortcut.setIcon("smart-home")
shortcut.setActive(false)
shortcut.setEnabled(false)
return
end
local eid = entity.entity_id
local pending = pendingToggle[eid]
if pending then
shortcut.setLabel(entity.friendly_name or eid)
shortcut.setIcon("loader")
shortcut.setActive(pending == "on")
shortcut.setEnabled(false)
return
end
local isOn = entity.state == "on"
shortcut.setLabel(entity.friendly_name or eid)
shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false))
shortcut.setActive(isOn)
shortcut.setEnabled(true)
end
noctalia.state.watch("entities", function(value)
entityStates = value or {}
reconcilePending(os.clock())
render()
end)
noctalia.state.watch("connection_status", function(_value)
render()
end)
function onClick()
reconcilePending(os.clock())
local entity = getEntity()
local cleanToken = getCleanToken()
if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end
local eid = entity.entity_id
if pendingToggle[eid] then return end
local newState = entity.state == "on" and "off" or "on"
pendingToggle[eid] = newState
schedulePendingTimeout(eid)
local encoded = noctalia.json.encode({ entity_id = eid })
noctalia.http({
url = haUrl .. "/api/services/" .. entity.domain .. "/toggle",
method = "POST",
headers = {
"Authorization: Bearer " .. cleanToken,
"Content-Type: application/json",
},
body = encoded,
}, function(response)end)
render()
end
entityStates = noctalia.state.get("entities") or {}
render()
+144
View File
@@ -0,0 +1,144 @@
--!nonstrict
local haUrl = noctalia.getConfig("ha_url")
local haToken = noctalia.getConfig("ha_token")
local configuredEntityId = noctalia.getConfig("shortcut_entity_2") or ""
local SLOT_LABEL = noctalia.tr("shortcut.label_2")
local entityStates = {}
local pendingToggle = {}
local pendingSince = {}
local pendingTimerToken = {}
local pendingTimerSeq = 0
local PENDING_TIMEOUT_SECONDS = 8
local render
local function getCleanToken()
if type(haToken) ~= "string" then return "" end
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
end
local function stateGlyph(domain, isOn)
if domain == "light" then return isOn and "bulb" or "bulb-off"
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
elseif domain == "fan" then return isOn and "wind" or "wind-off"
elseif domain == "lock" then return isOn and "lock" or "lock-open"
elseif domain == "cover" then return isOn and "door-open" or "door"
end
return "smart-home"
end
local function getEntity()
if configuredEntityId == "" then return nil end
return entityStates[configuredEntityId]
end
local function clearPending(entityId)
pendingToggle[entityId] = nil
pendingSince[entityId] = nil
pendingTimerToken[entityId] = nil
end
local function isPendingTimedOut(entityId, now)
local startedAt = pendingSince[entityId]
if not startedAt then return false end
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
end
local function reconcilePending(now)
for entityId, expected in pairs(pendingToggle) do
local real = entityStates[entityId]
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
clearPending(entityId)
end
end
end
local function schedulePendingTimeout(entityId)
pendingTimerSeq = pendingTimerSeq + 1
local token = pendingTimerSeq
pendingSince[entityId] = os.clock()
pendingTimerToken[entityId] = token
noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result)
if pendingTimerToken[entityId] ~= token then return end
if pendingToggle[entityId] and isPendingTimedOut(entityId) then
clearPending(entityId)
render()
end
end)
end
render = function()
reconcilePending(os.clock())
local entity = getEntity()
if not entity then
shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL)
shortcut.setIcon("smart-home")
shortcut.setActive(false)
shortcut.setEnabled(false)
return
end
local eid = entity.entity_id
local pending = pendingToggle[eid]
if pending then
shortcut.setLabel(entity.friendly_name or eid)
shortcut.setIcon("loader")
shortcut.setActive(pending == "on")
shortcut.setEnabled(false)
return
end
local isOn = entity.state == "on"
shortcut.setLabel(entity.friendly_name or eid)
shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false))
shortcut.setActive(isOn)
shortcut.setEnabled(true)
end
noctalia.state.watch("entities", function(value)
entityStates = value or {}
reconcilePending(os.clock())
render()
end)
noctalia.state.watch("connection_status", function(_value)
render()
end)
function onClick()
reconcilePending(os.clock())
local entity = getEntity()
local cleanToken = getCleanToken()
if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end
local eid = entity.entity_id
if pendingToggle[eid] then return end
local newState = entity.state == "on" and "off" or "on"
pendingToggle[eid] = newState
schedulePendingTimeout(eid)
local encoded = noctalia.json.encode({ entity_id = eid })
noctalia.http({
url = haUrl .. "/api/services/" .. entity.domain .. "/toggle",
method = "POST",
headers = {
"Authorization: Bearer " .. cleanToken,
"Content-Type: application/json",
},
body = encoded,
}, function(response)end)
render()
end
entityStates = noctalia.state.get("entities") or {}
render()
+144
View File
@@ -0,0 +1,144 @@
--!nonstrict
local haUrl = noctalia.getConfig("ha_url")
local haToken = noctalia.getConfig("ha_token")
local configuredEntityId = noctalia.getConfig("shortcut_entity_3") or ""
local SLOT_LABEL = noctalia.tr("shortcut.label_3")
local entityStates = {}
local pendingToggle = {}
local pendingSince = {}
local pendingTimerToken = {}
local pendingTimerSeq = 0
local PENDING_TIMEOUT_SECONDS = 8
local render
local function getCleanToken()
if type(haToken) ~= "string" then return "" end
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
end
local function stateGlyph(domain, isOn)
if domain == "light" then return isOn and "bulb" or "bulb-off"
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
elseif domain == "fan" then return isOn and "wind" or "wind-off"
elseif domain == "lock" then return isOn and "lock" or "lock-open"
elseif domain == "cover" then return isOn and "door-open" or "door"
end
return "smart-home"
end
local function getEntity()
if configuredEntityId == "" then return nil end
return entityStates[configuredEntityId]
end
local function clearPending(entityId)
pendingToggle[entityId] = nil
pendingSince[entityId] = nil
pendingTimerToken[entityId] = nil
end
local function isPendingTimedOut(entityId, now)
local startedAt = pendingSince[entityId]
if not startedAt then return false end
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
end
local function reconcilePending(now)
for entityId, expected in pairs(pendingToggle) do
local real = entityStates[entityId]
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
clearPending(entityId)
end
end
end
local function schedulePendingTimeout(entityId)
pendingTimerSeq = pendingTimerSeq + 1
local token = pendingTimerSeq
pendingSince[entityId] = os.clock()
pendingTimerToken[entityId] = token
noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result)
if pendingTimerToken[entityId] ~= token then return end
if pendingToggle[entityId] and isPendingTimedOut(entityId) then
clearPending(entityId)
render()
end
end)
end
render = function()
reconcilePending(os.clock())
local entity = getEntity()
if not entity then
shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL)
shortcut.setIcon("smart-home")
shortcut.setActive(false)
shortcut.setEnabled(false)
return
end
local eid = entity.entity_id
local pending = pendingToggle[eid]
if pending then
shortcut.setLabel(entity.friendly_name or eid)
shortcut.setIcon("loader")
shortcut.setActive(pending == "on")
shortcut.setEnabled(false)
return
end
local isOn = entity.state == "on"
shortcut.setLabel(entity.friendly_name or eid)
shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false))
shortcut.setActive(isOn)
shortcut.setEnabled(true)
end
noctalia.state.watch("entities", function(value)
entityStates = value or {}
reconcilePending(os.clock())
render()
end)
noctalia.state.watch("connection_status", function(_value)
render()
end)
function onClick()
reconcilePending(os.clock())
local entity = getEntity()
local cleanToken = getCleanToken()
if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end
local eid = entity.entity_id
if pendingToggle[eid] then return end
local newState = entity.state == "on" and "off" or "on"
pendingToggle[eid] = newState
schedulePendingTimeout(eid)
local encoded = noctalia.json.encode({ entity_id = eid })
noctalia.http({
url = haUrl .. "/api/services/" .. entity.domain .. "/toggle",
method = "POST",
headers = {
"Authorization: Bearer " .. cleanToken,
"Content-Type: application/json",
},
body = encoded,
}, function(response)end)
render()
end
entityStates = noctalia.state.get("entities") or {}
render()
+144
View File
@@ -0,0 +1,144 @@
--!nonstrict
local haUrl = noctalia.getConfig("ha_url")
local haToken = noctalia.getConfig("ha_token")
local configuredEntityId = noctalia.getConfig("shortcut_entity_4") or ""
local SLOT_LABEL = noctalia.tr("shortcut.label_4")
local entityStates = {}
local pendingToggle = {}
local pendingSince = {}
local pendingTimerToken = {}
local pendingTimerSeq = 0
local PENDING_TIMEOUT_SECONDS = 8
local render
local function getCleanToken()
if type(haToken) ~= "string" then return "" end
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
end
local function stateGlyph(domain, isOn)
if domain == "light" then return isOn and "bulb" or "bulb-off"
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
elseif domain == "fan" then return isOn and "wind" or "wind-off"
elseif domain == "lock" then return isOn and "lock" or "lock-open"
elseif domain == "cover" then return isOn and "door-open" or "door"
end
return "smart-home"
end
local function getEntity()
if configuredEntityId == "" then return nil end
return entityStates[configuredEntityId]
end
local function clearPending(entityId)
pendingToggle[entityId] = nil
pendingSince[entityId] = nil
pendingTimerToken[entityId] = nil
end
local function isPendingTimedOut(entityId, now)
local startedAt = pendingSince[entityId]
if not startedAt then return false end
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
end
local function reconcilePending(now)
for entityId, expected in pairs(pendingToggle) do
local real = entityStates[entityId]
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
clearPending(entityId)
end
end
end
local function schedulePendingTimeout(entityId)
pendingTimerSeq = pendingTimerSeq + 1
local token = pendingTimerSeq
pendingSince[entityId] = os.clock()
pendingTimerToken[entityId] = token
noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result)
if pendingTimerToken[entityId] ~= token then return end
if pendingToggle[entityId] and isPendingTimedOut(entityId) then
clearPending(entityId)
render()
end
end)
end
render = function()
reconcilePending(os.clock())
local entity = getEntity()
if not entity then
shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL)
shortcut.setIcon("smart-home")
shortcut.setActive(false)
shortcut.setEnabled(false)
return
end
local eid = entity.entity_id
local pending = pendingToggle[eid]
if pending then
shortcut.setLabel(entity.friendly_name or eid)
shortcut.setIcon("loader")
shortcut.setActive(pending == "on")
shortcut.setEnabled(false)
return
end
local isOn = entity.state == "on"
shortcut.setLabel(entity.friendly_name or eid)
shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false))
shortcut.setActive(isOn)
shortcut.setEnabled(true)
end
noctalia.state.watch("entities", function(value)
entityStates = value or {}
reconcilePending(os.clock())
render()
end)
noctalia.state.watch("connection_status", function(_value)
render()
end)
function onClick()
reconcilePending(os.clock())
local entity = getEntity()
local cleanToken = getCleanToken()
if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end
local eid = entity.entity_id
if pendingToggle[eid] then return end
local newState = entity.state == "on" and "off" or "on"
pendingToggle[eid] = newState
schedulePendingTimeout(eid)
local encoded = noctalia.json.encode({ entity_id = eid })
noctalia.http({
url = haUrl .. "/api/services/" .. entity.domain .. "/toggle",
method = "POST",
headers = {
"Authorization: Bearer " .. cleanToken,
"Content-Type: application/json",
},
body = encoded,
}, function(response)end)
render()
end
entityStates = noctalia.state.get("entities") or {}
render()
+10
View File
@@ -0,0 +1,10 @@
--!nonstrict
shortcut.setLabel(noctalia.tr("shortcut.panel_label"))
shortcut.setIcon("layout-list")
shortcut.setActive(false)
shortcut.setEnabled(true)
function onClick()
noctalia.togglePanel("pozzoo/hassio:entity_manager")
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+71
View File
@@ -0,0 +1,71 @@
{
"notifications": {
"auth_failed": "Authentication failed. Check your access token.",
"reconnect_failed": "Unable to reconnect to Home Assistant after {count} attempts."
},
"panel": {
"auth_failed": "Authentication failed. Check your access token.",
"brightness": "Brightness",
"color_temp": "Color Temperature",
"connecting": "Connecting…",
"disconnected_reconnecting": "Disconnected. Reconnecting…",
"hue": "Hue",
"loading_entities": "Loading entities…",
"manage_entities_title": "Manage Entities",
"no_entities_found": "No entities found",
"no_entities_match": "No entities match your search",
"no_entities_monitored": "No entities monitored yet. Use the browser to add some.",
"not_configured": "Not configured. Set your Home Assistant URL and token in settings.",
"save_failed": "Failed to save monitored entities",
"save_failed_no_data_dir": "Could not save: plugin data directory unavailable",
"search_placeholder": "Search entities…",
"state_on_brightness": "On · {percent}%",
"status_fallback": "Status: {status}"
},
"settings": {
"shortcut_entity": {
"description": "Home Assistant entity ID to show and toggle on this tile (e.g. light.living_room)."
},
"shortcut_entity_1": {
"label": "Quick Toggle 1 - Entity ID"
},
"shortcut_entity_2": {
"label": "Quick Toggle 2 - Entity ID"
},
"shortcut_entity_3": {
"label": "Quick Toggle 3 - Entity ID"
},
"shortcut_entity_4": {
"label": "Quick Toggle 4 - Entity ID"
},
"show_entity_count": {
"label": "Show entity count in widget"
},
"token": {
"description": "Create a token in Home Assistant under Profile → Security → Long-lived access tokens",
"label": "Long-Lived Access Token"
},
"url": {
"description": "The URL to your Home Assistant instance (e.g., http://homeassistant.local:8123)",
"label": "Home Assistant URL"
}
},
"shortcut": {
"label_1": "Quick Toggle 1",
"label_2": "Quick Toggle 2",
"label_3": "Quick Toggle 3",
"label_4": "Quick Toggle 4",
"panel_label": "Entity Manager",
"title": "Home Assistant"
},
"widget": {
"refreshing_connection": "Refreshing connection…",
"status_auth_failed": "Auth Failed",
"status_connected": "Connected",
"status_connecting": "Connecting",
"status_disconnected": "Disconnected",
"status_unconfigured": "Not Configured",
"tooltip": "Home Assistant: {status}",
"tooltip_entities": "Home Assistant: {status} ({count} entities)"
}
}
+71
View File
@@ -0,0 +1,71 @@
{
"notifications": {
"auth_failed": "Falha na autenticação. Verifique seu token de acesso.",
"reconnect_failed": "Não foi possível reconectar ao Home Assistant após {count} tentativas."
},
"panel": {
"auth_failed": "Falha na autenticação. Verifique seu token de acesso.",
"brightness": "Brilho",
"color_temp": "Temperatura de Cor",
"connecting": "Conectando…",
"disconnected_reconnecting": "Desconectado. Reconectando…",
"hue": "Matiz",
"loading_entities": "Carregando entidades…",
"manage_entities_title": "Gerenciar Entidades",
"no_entities_found": "Nenhuma entidade encontrada",
"no_entities_match": "Nenhuma entidade corresponde à sua busca",
"no_entities_monitored": "Nenhuma entidade monitorada ainda. Use o navegador para adicionar.",
"not_configured": "Não configurado. Defina a URL e o token do Home Assistant nas configurações.",
"save_failed": "Falha ao salvar as entidades monitoradas",
"save_failed_no_data_dir": "Não foi possível salvar: diretório de dados do plugin indisponível",
"search_placeholder": "Buscar entidades…",
"state_on_brightness": "Ligado · {percent}%",
"status_fallback": "Status: {status}"
},
"settings": {
"shortcut_entity": {
"description": "ID da entidade do Home Assistant para mostrar e alternar neste espaço (e.g. light.living_room)."
},
"shortcut_entity_1": {
"label": "Botão Rápido 1 - ID da Entidade"
},
"shortcut_entity_2": {
"label": "Botão Rápido 2 - ID da Entidade"
},
"shortcut_entity_3": {
"label": "Botão Rápido 3 - ID da Entidade"
},
"shortcut_entity_4": {
"label": "Botão Rápido 4 - ID da Entidade"
},
"show_entity_count": {
"label": "Mostrar o Número de Entidades"
},
"token": {
"description": "Crie um token no Home Assistant em Profile → Security → Long-lived access tokens",
"label": "Token de Acesso Longo"
},
"url": {
"description": "A URL para sua instância do Home Assistant (e.g., http://homeassistant.local:8123)",
"label": "URL do Home Assistant"
}
},
"shortcut": {
"label_1": "Botão Rápido 1",
"label_2": "Botão Rápido 2",
"label_3": "Botão Rápido 3",
"label_4": "Botão Rápido 4",
"panel_label": "Gerenciador de Entidades",
"title": "Home Assistant"
},
"widget": {
"refreshing_connection": "Atualizando conexão…",
"status_auth_failed": "Falha de Autenticação",
"status_connected": "Conectado",
"status_connecting": "Conectando",
"status_disconnected": "Desconectado",
"status_unconfigured": "Não Configurado",
"tooltip": "Home Assistant: {status}",
"tooltip_entities": "Home Assistant: {status} ({count} entidades)"
}
}
+96
View File
@@ -0,0 +1,96 @@
--!nonstrict
local haUrl = noctalia.getConfig("ha_url")
local showEntityCount = noctalia.getConfig("show_entity_count")
local status = "unconfigured"
local entityCount = 0
local render
local function tr(key, subst)
return noctalia.tr("widget." .. key, subst)
end
noctalia.state.watch("connection_status", function(value)
status = value or "unconfigured"
if render then render() end
end)
noctalia.state.watch("entity_count", function(value)
entityCount = value or 0
if render then render() end
end)
local function getStatusText()
if status == "connected" then
return tr("status_connected")
elseif status == "connecting" then
return tr("status_connecting")
elseif status == "disconnected" then
return tr("status_disconnected")
elseif status == "auth_failed" then
return tr("status_auth_failed")
else
return tr("status_unconfigured")
end
end
local function getStatusColor()
if status == "connected" then
return "primary"
elseif status == "connecting" then
return "on_error"
elseif status == "disconnected" or status == "auth_failed" then
return "error"
else
return "on_surface_variant"
end
end
render = function()
barWidget.setGlyph("smart-home")
barWidget.setGlyphColor(getStatusColor())
if showEntityCount and entityCount > 0 then
barWidget.setText(tostring(entityCount))
barWidget.setTooltip(tr("tooltip_entities", {
status = getStatusText(),
count = entityCount
}))
else
barWidget.setTooltip(tr("tooltip", {
status = getStatusText()
}))
end
end
function update()
noctalia.setUpdateInterval(5000)
if status == "unconfigured" then
local serviceStatus = noctalia.state.get("connection_status")
if serviceStatus then
status = serviceStatus
end
end
render()
end
function onClick()
noctalia.togglePanel("pozzoo/hassio:entity_manager")
end
function onRightClick()
if haUrl and haUrl ~= "" then
noctalia.runAsync("xdg-open " .. ("'" .. haUrl:gsub("'", "'\\''") .. "'"))
end
end
function onIpc(event, payload)
if event == "refresh" then
noctalia.state.set("command", "refresh")
noctalia.notify(noctalia.tr("shortcut.title"), tr("refreshing_connection"))
end
end
+12 -12
View File
@@ -1,19 +1,19 @@
{ {
"title": "Conservation Mode",
"shortcut": {
"on": "Conservation On",
"off": "Conservation Off",
"na": "Conservation N/A"
},
"notify": {
"enabled": "Enabled - charging capped for battery health.",
"disabled": "Disabled - charging to 100%.",
"setup_needed": "One-time setup needed - check the terminal window that just opened."
},
"error": { "error": {
"device_not_found": "Could not find the ideapad_acpi conservation_mode sysfs file on this machine.", "device_not_found": "Could not find the ideapad_acpi conservation_mode sysfs file on this machine.",
"read_failed": "Could not read the current conservation_mode state.", "read_failed": "Could not read the current conservation_mode state.",
"write_failed": "Failed to write conservation_mode ({error}) and could not locate the plugin directory to run setup.", "write_failed": "Failed to write conservation_mode ({error}) and could not locate the plugin directory to run setup.",
"write_failed_manual": "Failed to write conservation_mode ({error}). Run scripts/setup-permissions.sh with sudo manually, then log out and back in." "write_failed_manual": "Failed to write conservation_mode ({error}). Run scripts/setup-permissions.sh with sudo manually, then log out and back in."
} },
"notify": {
"disabled": "Disabled - charging to 100%.",
"enabled": "Enabled - charging capped for battery health.",
"setup_needed": "One-time setup needed - check the terminal window that just opened."
},
"shortcut": {
"na": "Conservation N/A",
"off": "Conservation Off",
"on": "Conservation On"
},
"title": "Conservation Mode"
} }
+43 -27
View File
@@ -1,29 +1,45 @@
{ {
"settings.iio.label": "IIO Tool", "panel": {
"settings.iio.description": "Executable used to control orientation", "lock": "Toggle Lock",
"settings.iio.options.sway":"iio-sway", "title": "iio-lock"
"settings.iio.options.hyprland":"iio-hyprland", },
"settings": {
"settings.transform.label": "Transform Order", "glyph": {
"settings.transform.description":"Rotation values for Up, Left, Down, Right.", "locked": {
"description": "Icon displayed when the device is locked.",
"settings.wm.label": "Window Manager", "label": "Locked Glyph"
"settings.wm.description": "Choose the window manager backend.", },
"settings.wm.options.sway":"Sway", "unlocked": {
"settings.wm.options.hyprland":"Hyprland", "description": "Icon displayed when the device is unlocked.",
"label": "Unlocked Glyph"
}
"settings.glyph.locked.label": "Locked Glyph", },
"settings.glyph.locked.description": "Icon displayed when the device is locked.", "iio": {
"description": "Executable used to control orientation",
"settings.glyph.unlocked.label": "Unlocked Glyph", "label": "IIO Tool",
"settings.glyph.unlocked.description": "Icon displayed when the device is unlocked.", "options": {
"hyprland": "iio-hyprland",
"sway": "iio-sway"
"panel.title": "iio-lock", }
"panel.lock": "Toggle Lock", },
"transform": {
"widget.locked": "Locked", "description": "Rotation values for Up, Left, Down, Right.",
"widget.unlocked": "Unlocked", "label": "Transform Order"
"toggle.description": "Toggle orientation lock" },
"wm": {
"description": "Choose the window manager backend.",
"label": "Window Manager",
"options": {
"hyprland": "Hyprland",
"sway": "Sway"
}
}
},
"toggle": {
"description": "Toggle orientation lock"
},
"widget": {
"locked": "Locked",
"unlocked": "Unlocked"
}
} }
+118
View File
@@ -0,0 +1,118 @@
# Lyrics
Lyrics adds a synchronized status-bar lyric display with album artwork, karaoke
highlighting, animated line changes, and configurable online or local sources.
## Plugin
| Field | Value |
| --- | --- |
| ID | `h465855hgg/lyrics` |
| Entries | Bar widget: `lyrics`; service: `service` |
## Requirements
Install `playerctl`, `python3`, and `cp` on `PATH`. The active media
player must expose MPRIS metadata for automatic track and playback detection.
Noctalia installs the plugin files; it does not install system packages for you.
To check or install the runtime packages automatically, run:
```sh
sh scripts/setup-deps.sh --check
sh scripts/setup-deps.sh
```
Use `--yes` for unattended installs. The script supports `apt`, `dnf`,
`pacman`, `zypper`, `apk`, and `xbps-install`.
## Usage
Enable `h465855hgg/lyrics`, then add the `lyrics` bar widget in Noctalia's bar
settings. The background `service` detects the active MPRIS player, resolves
lyrics, downloads or caches album artwork, and publishes playback state to the
widget.
When several players are available, the service prefers a playing player, then
a paused player, and keeps the current player when priorities are equal. The
optional allowlist and blocklist match player names or instances and support
`*` wildcards.
Left-click the widget to switch between lyrics and track information. Right-click
to pause or resume the active player. Paused content is dimmed and all lyric,
transition, and marquee animation stops until playback resumes.
When synchronized lyrics are unavailable, the widget displays
`track title + artist`. Long lines pause at each end while scrolling. Intro and
instrumental gaps can show a configurable cue such as `•••••`.
## Screenshots
Status-bar widget:
![Lyrics bar widget](screenshots/widget.webp)
Plugin settings:
![Lyrics settings](screenshots/settings.webp)
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `player_allowlist` | `string_list` | empty | Only uses matching MPRIS player names or instances; supports `*` wildcards. |
| `player_blocklist` | `string_list` | empty | Ignores matching MPRIS player names or instances; takes priority over the allowlist. |
| `lyrics_source` | `select` | `auto` | Selects automatic fallback, LRCLIB, public NetEase, MPRIS text, custom HTTP, or external IPC. |
| `custom_url` | `string` | empty | HTTP URL template with `{title}`, `{artist}`, `{album}`, and `{duration}` placeholders. |
| `custom_json_field` | `string` | `syncedLyrics` | Dotted field path containing an LRC string or timed-lines array in a JSON response. |
| `cue_text` | `string` | `•••••` | Characters highlighted through long intro or instrumental gaps. |
| `cue_font_mode` | `select` | `follow` | Follows Noctalia's interface font or uses a custom installed font for intro/interlude characters. |
| `cue_font_family` | `string` | `sans-serif` | Installed font family used for intro/interlude characters in custom-font mode. |
| `scroll_mode` | `select` | `auto` | Enables automatic marquee, forced marquee, or static truncation. |
| `marquee_speed` | `int` | `30` | Approximate long-line scroll speed in logical pixels per second. |
| `max_lines` | `int` | `1` | Number of lines shown on a vertical bar, from 1 to 3. |
| `gradient` | `bool` | `true` | Enables progressive per-character highlighting. |
| `animation` | `select` | `karaoke` | Chooses karaoke, cascade, wave, fade-only, or no line transition. |
| `max_chars` | `int` | `15` | Number of visible Unicode characters before marquee scrolling starts. |
| `char_width` | `int` | `9` | Estimated logical-pixel character width used for scroll timing and minimum layout width. |
| `glyph` | `glyph` | `music` | Fallback icon shown when album artwork is unavailable. |
| `show_artist` | `bool` | `true` | Includes the artist in track-information mode. |
| `hide_when_paused` | `bool` | `false` | Hides the widget instead of dimming it while paused. |
| `show_cover` | `bool` | `true` | Shows circular album artwork beside the lyrics. |
| `active_color` | `color` | `on_surface` | Colors the current and already-sung lyric characters. |
| `inactive_color` | `color` | `on_surface_variant` | Colors upcoming lyrics, paused playback, and secondary lines. |
## IPC
External players can set `lyrics_source` to `external` and address the singleton
service with:
```sh
noctalia msg plugin h465855hgg/lyrics:service all <event> '<payload>'
```
Supported events:
- `push-lrc`: accepts synchronized or plain LRC text.
- `push-json`: accepts JSON with a `lines` timed array or a `lyrics` LRC string.
- `push-state`: also accepts `track`, `position`, `playing`, and `cover` fields.
- `clear`: clears the currently published lyrics.
Line timestamps and character timestamps are milliseconds. MPRIS track duration
and playback position are microseconds:
```json
{"lines":[{"time":1200,"duration":1800,"text":"Hello","chars":[1200,1500,1800,2100,2400]}]}
```
## Notes
Automatic mode requests LRCLIB first, then the public NetEase Music API. Custom
HTTP mode contacts only the configured endpoint. The plugin never reads browser
cookies or player credentials.
The service runs `playerctl` to select, read, and control MPRIS playback, `python3` for the
LRCLIB helper and dynamic-lyric parser, and `cp` to preserve temporary local cover
files. Public NetEase requests use Noctalia's HTTP API. Query scratch files and
downloaded cover images are written inside the plugin runtime directory. Remote
code is never downloaded or executed.
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# Decode NetEase KRC ("klyric") dynamic lyrics into per-character timings.
# Reads the klyric field (base64 of "krc1" + zlib stream) from argv[1],
# writes JSON: {"type":"krc","lines":[{"time":ms,"text":"...","chars":[ms,...]}]}
import sys, json, base64, zlib, re
def find_zlib(buf):
for i in range(len(buf) - 1):
if buf[i] == 0x78 and buf[i + 1] in (0x01, 0x9c, 0xda):
try:
return zlib.decompress(buf[i:])
except Exception:
continue
return None
def decode_krc(raw):
if isinstance(raw, str) and re.search(r"^\[\d+,\d+\]", raw, re.MULTILINE):
return raw
data = None
if isinstance(raw, str):
try:
data = base64.b64decode(raw)
except Exception:
data = raw.encode("latin-1")
else:
data = raw
if data[:4] == b"krc1":
data = data[4:]
dec = find_zlib(data)
if dec is None:
return None
try:
return dec.decode("utf-8")
except Exception:
return dec.decode("utf-8", "ignore")
LINE_RE = re.compile(r"^\[(\d+),(\d+)\](.*)$")
PREFIX_SYL_RE = re.compile(r"(?:<|\()(\d+),(\d+)(?:,\d+)?(?:>|\))([^<(]*)")
SUFFIX_SYL_RE = re.compile(r"(.*?)<(\d+),(\d+)(?:,\d+)?>")
def parse_krc(text):
out = []
for line in text.splitlines():
m = LINE_RE.match(line)
if not m:
continue
start = int(m.group(1))
dur = int(m.group(2))
body = m.group(3)
# NetEase has shipped both `(offset,duration,0)word` and
# `word<offset,duration>` variants of its word-synced format.
prefixed = PREFIX_SYL_RE.findall(body) if re.match(r"^[<(]\d+,", body) else []
if prefixed:
syl = [(word, int(offset), int(duration))
for offset, duration, word in prefixed]
else:
syl = [(word, int(offset), int(duration))
for word, offset, duration in SUFFIX_SYL_RE.findall(body)]
if not syl and body.strip() != "":
syl = [(body, 0, dur)]
chars = []
full = ""
for (word, so, sd) in syl:
n = len(word)
if n == 0:
continue
for j in range(n):
chars.append(start + so + (j * sd) // n)
full += word[j]
if full.strip() != "":
out.append({"time": start, "duration": dur, "text": full, "chars": chars})
return out
def main():
if len(sys.argv) < 2:
print(json.dumps({"type": "none"}))
return
try:
with open(sys.argv[1], "r", encoding="utf-8") as f:
raw = f.read()
except Exception:
print(json.dumps({"type": "none"}))
return
text = decode_krc(raw)
if text is None:
print(json.dumps({"type": "none"}))
return
print(json.dumps({"type": "krc", "lines": parse_krc(text)}, ensure_ascii=False))
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
import sys, os, json, urllib.request, urllib.parse
LRCLIB = "https://lrclib.net/api/search"
def norm(s):
return "".join(ch for ch in (s or "") if ch.isalnum() or "\u4e00" <= ch <= "\u9fff").lower()
def http_get(url):
req = urllib.request.Request(url, headers={
"User-Agent": "lyrics-plugin/1.0",
"Accept": "application/json",
})
with urllib.request.urlopen(req, timeout=15) as r:
return r.read().decode("utf-8", "ignore")
def main():
raw = sys.argv[1] if len(sys.argv) > 1 else ""
title, artist, album = "test", "", ""
if raw and os.path.isfile(raw):
try:
with open(raw, encoding="utf-8") as f:
lines = [l.strip() for l in f.read().splitlines()]
title = lines[0] if lines else "test"
artist = lines[1] if len(lines) > 1 else ""
album = lines[2] if len(lines) > 2 else ""
except Exception as e:
out = {"type": "none", "lines": [], "lrc": "", "diag": [f"query_file_read_err={e!r}"]}
print(json.dumps(out, ensure_ascii=False))
return
else:
title = raw or "test"
out = {"type": "none", "lines": [], "lrc": "", "diag": []}
try:
q = urllib.parse.urlencode({"track_name": title, "artist_name": artist or title})
s = json.loads(http_get(LRCLIB + "?" + q))
if not s:
out["diag"].append("lrclib: no results")
print(json.dumps(out, ensure_ascii=False))
return
nart = norm(artist)
nalb = norm(album)
ntitle = norm(title)
best = None
for c in s:
cn = norm(c.get("trackName", ""))
ca = norm(c.get("artistName", ""))
if ntitle and cn != ntitle and ntitle not in cn and cn not in ntitle:
continue
if nart and ca and (nart in ca or ca in nart):
best = c
break
if nalb and norm(c.get("albumName", "")) == nalb:
best = c
break
if best is None:
best = c
if best is None:
best = s[0]
lrc = best.get("syncedLyrics") or best.get("plainLyrics") or ""
if not lrc:
out["diag"].append("lrclib: empty lyrics")
print(json.dumps(out, ensure_ascii=False))
return
out["lrc"] = lrc
out["type"] = "lrc"
out["diag"].append(f"lrclib: {best.get('trackName')} / {best.get('artistName')} synced={bool(best.get('syncedLyrics'))}")
print(json.dumps(out, ensure_ascii=False))
except Exception as e:
out["diag"].append(f"lrclib ERR: {e!r}")
print(json.dumps(out, ensure_ascii=False))
if __name__ == "__main__":
main()
+472
View File
@@ -0,0 +1,472 @@
--!nonstrict
-- Synchronized lyrics bar widget with karaoke highlighting and long-line scrolling.
noctalia.setUpdateInterval(33)
local glyph = noctalia.getConfig("glyph")
local showArtist = noctalia.getConfig("show_artist")
local hideWhenPaused = noctalia.getConfig("hide_when_paused")
local showCover = noctalia.getConfig("show_cover")
if showCover == nil then showCover = true end
local activeColor = noctalia.getConfig("active_color") or "on_surface"
local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant"
-- These are plugin settings, not per-widget settings.
local scrollMode = noctalia.getConfig("scroll_mode") or "auto"
local marqueeSpeed = tonumber(noctalia.getConfig("marquee_speed")) or 30
local maxLines = tonumber(noctalia.getConfig("max_lines")) or 1
local maxChars = tonumber(noctalia.getConfig("max_chars")) or 15
local charWidth = tonumber(noctalia.getConfig("char_width")) or 9
local gradientOn = noctalia.getConfig("gradient")
if gradientOn == nil then gradientOn = true end
local animation = noctalia.getConfig("animation") or "karaoke"
local cueText = noctalia.getConfig("cue_text") or "•••••"
if cueText == "" then cueText = "•••••" end
local cueFontMode = noctalia.getConfig("cue_font_mode") or "follow"
local cueFontFamily = noctalia.getConfig("cue_font_family") or "sans-serif"
if cueFontFamily == "" then cueFontFamily = "sans-serif" end
local track = nil
local lyrics = nil
local cover = nil
local playing = false
local showMode = "auto"
local rendered = false
local playerInstance = ""
local baselinePosition = 0
local baselineClock = os.clock()
local lastClock = 0
local displayKey = ""
local displayedLine = nil
local outgoingLine = nil
local transitionElapsed = 0
local marqueeElapsed = 0
local smoothProgress = 0
local FADE_OUT_MS = 130
local FADE_IN_MS = 260
local MARQUEE_HOLD = 1.1
local BLEND_MS = 350
local INTERLUDE_GAP_MS = 7000
local INTRO_MIN_MS = 6000
local INTERLUDE_MIN_MS = 8000
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function clamp(value, low, high)
return math.min(high, math.max(low, value))
end
local function easeOutCubic(t)
local u = 1 - clamp(t, 0, 1)
return 1 - u * u * u
end
local function toChars(text)
local chars = {}
local i = 1
while i <= #text do
local byte = text:byte(i)
local length = 1
if byte >= 0xF0 then length = 4
elseif byte >= 0xE0 then length = 3
elseif byte >= 0xC0 then length = 2 end
chars[#chars + 1] = text:sub(i, i + length - 1)
i = i + length
end
return chars
end
local function getProgressMs()
local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0
return baselinePosition / 1000 + elapsed
end
local function getLineInfo()
if not lyrics or #lyrics == 0 or not track then return nil end
local position = getProgressMs()
local synced = lyrics[1].time >= 0
if not synced then
return { key = "plain", index = 1, line = lyrics[1], progress = 0, synced = false }
end
local firstTime = lyrics[1].time
if firstTime >= INTRO_MIN_MS and position < firstTime then
return {
key = "intro",
index = 0,
line = { text = cueText, cue = true },
progress = clamp(position / firstTime, 0, 1),
synced = true,
position = position,
cue = true,
}
end
local index = 1
for i = #lyrics, 1, -1 do
if lyrics[i].time <= position then
index = i
break
end
end
local line = lyrics[index]
local startTime = line.time
local durationMs = track.duration and track.duration > 0 and track.duration / 1000 or nil
local nextLine = lyrics[index + 1]
local nextTime = nextLine and nextLine.time
or durationMs
or (startTime + 4000)
local lineEnd = nextTime
if line.duration and line.duration > 0 then
lineEnd = math.min(nextTime, startTime + line.duration)
elseif nextTime - startTime >= INTERLUDE_GAP_MS then
-- LRC only marks line starts. Do not stretch a lyric across a long instrumental gap.
local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000)
lineEnd = math.min(nextTime, startTime + estimatedDuration)
end
-- Only show an interlude between two real lyric lines. The final lyric stays
-- visible through the outro instead of turning the whole song ending into dots.
if nextLine and nextTime - lineEnd >= INTERLUDE_MIN_MS and position >= lineEnd then
return {
key = "interlude-" .. tostring(index),
index = index,
line = { text = cueText, cue = true },
progress = clamp((position - lineEnd) / (nextTime - lineEnd), 0, 1),
synced = true,
position = position,
cue = true,
}
end
local progress = lineEnd > startTime and clamp((position - startTime) / (lineEnd - startTime), 0, 1) or 1
return {
key = "line-" .. tostring(index),
index = index,
line = line,
progress = progress,
synced = true,
position = position,
}
end
local function getTrackLabel()
if not track then return "--" end
if not showArtist then return track.title end
if track.artist and track.artist ~= "" then
return track.artist .. " - " .. track.title
end
return track.title
end
local function getFallbackLabel()
if not track then return "--" end
if track.artist and track.artist ~= "" then
return track.title .. " + " .. track.artist
end
return track.title
end
local function shouldMarquee(text)
return scrollMode ~= "static" and #toChars(text) > maxChars
end
local function getMarqueeOffset(length)
local distance = math.max(0, length - maxChars)
if distance == 0 then return 0 end
-- The setting is pixels per second; convert it to character cells per second.
local charsPerSecond = math.max(0.5, marqueeSpeed / math.max(1, charWidth))
local travelTime = distance / charsPerSecond
local cycle = MARQUEE_HOLD * 2 + travelTime * 2
local phase = marqueeElapsed % cycle
if phase < MARQUEE_HOLD then return 0 end
phase = phase - MARQUEE_HOLD
if phase < travelTime then return distance * (phase / travelTime) end
phase = phase - travelTime
if phase < MARQUEE_HOLD then return distance end
phase = phase - MARQUEE_HOLD
return distance * (1 - phase / travelTime)
end
local function karaokeColor(charTime, charProgress, position, useGradient)
if not playing then return inactiveColor end
local active = activeColor
local inactive = inactiveColor
if charTime and position then
if not useGradient then
return position >= charTime and active or inactive
end
if position >= charTime then return active end
local distance = charTime - position
if distance >= BLEND_MS then return inactive end
local alpha = 1 - distance / BLEND_MS
if alpha <= 0.5 then return inactive end
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
end
if not useGradient then
return smoothProgress >= charProgress and active or inactive
end
local distance = charProgress - smoothProgress
if distance <= 0 then return active end
if distance >= 0.16 then return inactive end
local alpha = 1 - distance / 0.16
if alpha <= 0.5 then return inactive end
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
end
local function buildLineRow(line, opts)
opts = opts or {}
local text = type(line) == "table" and line.text or line
local times = type(line) == "table" and line.chars or nil
local isCue = opts.cue or (type(line) == "table" and line.cue == true)
local chars = toChars(text or "")
if #chars == 0 then chars = { " " } end
local exactOffset = opts.marquee and getMarqueeOffset(#chars) or 0
local offset = math.floor(exactOffset)
local fraction = exactOffset - offset
local first = offset + 1
local last = math.min(#chars, first + maxChars)
local labels = {}
for sourceIndex = first, last do
local active = playing and activeColor or inactiveColor
local color = opts.dim and inactiveColor or active
if not opts.solid and not opts.dim then
local progress = #chars > 1 and (sourceIndex - 1) / (#chars - 1) or 0
color = karaokeColor(times and times[sourceIndex], progress, opts.position, opts.useGradient)
end
-- Soft viewport edges make cell-by-cell marquee movement less abrupt.
local alpha = 1
local edgeAlpha = noctalia.isDarkMode() and 0.5 or 0.72
if opts.marquee and offset > 0 and sourceIndex == first then alpha = edgeAlpha end
if opts.marquee and last < #chars and sourceIndex == last then alpha = edgeAlpha end
if opts.cascade then
local charProgress = (sourceIndex - first) / math.max(1, last - first)
local cascadeProgress = clamp((transitionElapsed - FADE_OUT_MS) / 420, 0, 1)
alpha = alpha * clamp((cascadeProgress - charProgress * 0.45) * 2.4, 0.08, 1)
end
if opts.wave then
local waveProgress = clamp((transitionElapsed - FADE_OUT_MS) / 650, 0, 1)
local wave = 0.55 + 0.45 * math.sin(waveProgress * math.pi * 2 - sourceIndex * 0.78)
alpha = alpha * (wave * (1 - waveProgress) + waveProgress)
end
-- Bar labels elide glyphs when constrained below their natural width, so
-- animate the viewport edges with opacity without clipping CJK characters.
if opts.marquee and fraction > 0 then
if sourceIndex == first then
alpha = alpha * (1 - fraction)
elseif sourceIndex == first + maxChars then
alpha = alpha * fraction
end
elseif sourceIndex == first + maxChars then
alpha = 0
end
labels[#labels + 1] = ui.label({
key = "char-" .. tostring(sourceIndex),
text = chars[sourceIndex],
color = color,
opacity = alpha,
maxLines = 1,
fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or nil,
})
end
if opts.marquee then
while #labels < maxChars + 1 do
labels[#labels + 1] = ui.label({ text = " ", opacity = 0, maxLines = 1 })
end
end
return ui.row({
key = opts.key or "line",
gap = 0,
align = "center",
minWidth = maxChars * charWidth,
opacity = opts.opacity or 1,
}, labels)
end
local function currentTransitionLine(info)
if animation == "none" or transitionElapsed >= FADE_OUT_MS then
local opacity = animation == "none" and 1
or easeOutCubic((transitionElapsed - FADE_OUT_MS) / FADE_IN_MS)
return info and info.line or nil, opacity
end
return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS)
end
local function render()
if hideWhenPaused and not playing then
barWidget.setVisible(false)
return
end
barWidget.setVisible(true)
local vertical = barWidget.isVertical()
local useGradient = (animation == "karaoke" or animation == "cascade" or animation == "wave") and gradientOn
local position = getProgressMs()
local prefix = {}
local coverPath = showCover and cover and cover ~= "" and noctalia.fileExists(cover) and cover or nil
if coverPath then
prefix[#prefix + 1] = ui.image({ path = coverPath, width = 18, height = 18, radius = 9, fit = "cover" })
else
prefix[#prefix + 1] = ui.glyph({
name = glyph,
size = 14,
color = playing and activeColor or inactiveColor,
})
end
local lineNodes = {}
if showMode == "track" then
local label = getTrackLabel()
lineNodes[1] = buildLineRow(label, {
key = "track",
marquee = shouldMarquee(label),
solid = true,
})
else
local info = getLineInfo()
if info then
local shownLine, opacity = currentTransitionLine(info)
if shownLine then
lineNodes[#lineNodes + 1] = buildLineRow(shownLine, {
key = "current-" .. tostring(info.index),
marquee = shownLine == info.line and shouldMarquee(shownLine.text),
position = position,
useGradient = shownLine == info.line and info.synced and useGradient,
solid = not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"),
cascade = shownLine == info.line and animation == "cascade",
wave = shownLine == info.line and animation == "wave",
opacity = opacity,
cue = shownLine == info.line and info.cue == true,
})
end
if vertical and maxLines > 1 and shownLine == info.line then
for i = 1, maxLines - 1 do
local nextLine = lyrics[info.index + i]
if not nextLine then break end
lineNodes[#lineNodes + 1] = buildLineRow(nextLine, {
key = "next-" .. tostring(info.index + i),
dim = true,
opacity = math.max(0.25, 0.58 - (i - 1) * 0.16),
})
end
end
else
local label = getFallbackLabel()
lineNodes[1] = buildLineRow(label, {
key = "fallback",
marquee = shouldMarquee(label),
solid = true,
})
end
end
if vertical then
barWidget.render(ui.column({ gap = 3, align = "start", opacity = playing and 1 or 0.58 }, {
ui.row({ gap = 6, align = "center" }, prefix),
ui.column({ gap = 2, align = "start" }, lineNodes),
}))
else
local children = {}
for _, node in ipairs(prefix) do children[#children + 1] = node end
children[#children + 1] = lineNodes[1]
barWidget.render(ui.row({ gap = 6, align = "center", opacity = playing and 1 or 0.58 }, children))
end
barWidget.setTooltip(getTrackLabel())
rendered = true
end
function onClick()
showMode = showMode == "auto" and "track" or "auto"
marqueeElapsed = 0
if rendered then render() end
end
function onRightClick()
if playerInstance == "" then return end
noctalia.runAsync("playerctl --player " .. shellQuote(playerInstance) .. " play-pause", function(result)
if result.exitCode == 0 then
local wasPlaying = playing
if wasPlaying then baselinePosition = getProgressMs() * 1000 end
playing = not wasPlaying
noctalia.state.set("playing", playing)
baselineClock = os.clock()
render()
end
end)
end
function update()
local now = os.clock()
local delta = lastClock > 0 and now - lastClock or 0
lastClock = now
track = noctalia.state.get("track")
lyrics = noctalia.state.get("lyrics")
cover = noctalia.state.get("cover")
playing = noctalia.state.get("playing") == true
playerInstance = noctalia.state.get("player_instance") or ""
local info = getLineInfo()
local nextKey = info and info.key or "fallback"
if nextKey ~= displayKey then
outgoingLine = displayedLine
displayKey = nextKey
transitionElapsed = animation == "none" and FADE_OUT_MS + FADE_IN_MS or 0
marqueeElapsed = 0
smoothProgress = info and info.progress or 0
else
local target = info and info.progress or 0
smoothProgress = smoothProgress + (target - smoothProgress) * math.min(1, delta * 8)
end
displayedLine = info and info.line or nil
if playing then
transitionElapsed = transitionElapsed + delta * 1000
local text = showMode == "track" and getTrackLabel() or (info and info.line.text or "")
if shouldMarquee(text) then marqueeElapsed = marqueeElapsed + delta end
end
render()
end
noctalia.state.watch("position", function(value)
baselinePosition = tonumber(value) or 0
baselineClock = os.clock()
end)
noctalia.state.watch("lyrics", function(value)
lyrics = value
displayKey = ""
displayedLine = nil
outgoingLine = nil
marqueeElapsed = 0
end)
noctalia.state.watch("playing", function(value)
-- Preserve the interpolated position when pausing between service polls.
if playing and value ~= true then
baselinePosition = getProgressMs() * 1000
end
playing = value == true
baselineClock = os.clock()
end)
+593
View File
@@ -0,0 +1,593 @@
--!nonstrict
-- Lyrics — headless service.
-- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music
-- via /api endpoints, publishes state. No polling delay.
noctalia.setUpdateInterval(250)
local cache = {}
local coverCache = {}
local lastTrackKey = ""
local inFlight = nil
local coverInFlight = nil
local pluginDir = noctalia.pluginDir() or "/tmp"
local cacheDir = pluginDir .. "/.cache"
noctalia.mkdirAll(cacheDir)
local krcTmp = cacheDir .. "/krc.tmp"
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
local customUrl = noctalia.getConfig("custom_url") or ""
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local currentTrack = nil
local currentEmbeddedLyrics = ""
local currentPlayerInstance = ""
local function normalizePatterns(value)
if type(value) ~= "table" then return {} end
local patterns = {}
for _, pattern in ipairs(value) do
pattern = tostring(pattern):lower():gsub("^%s+", ""):gsub("%s+$", "")
if pattern ~= "" then patterns[#patterns + 1] = pattern end
end
table.sort(patterns)
return patterns
end
local playerAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
local playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local function trackKey(track)
return (track.playerInstance or "") .. "|" .. track.title .. "|" .. track.artist .. "|" .. track.album
end
local function patternMatches(value, pattern)
local luaPattern = "^" .. pattern:gsub("([%%%^%$%(%)%.%[%]%+%-%?])", "%%%1"):gsub("%*", ".*") .. "$"
return value:lower():match(luaPattern) ~= nil
end
local function matchesAny(player, patterns)
for _, pattern in ipairs(patterns) do
if patternMatches(player.name, pattern) or patternMatches(player.instance, pattern) then return true end
end
return false
end
local function playerAllowed(player)
if #playerAllowlist > 0 and not matchesAny(player, playerAllowlist) then return false end
return not matchesAny(player, playerBlocklist)
end
local function selectPlayer(players)
local best = nil
local bestRank = -1
for _, player in ipairs(players) do
if playerAllowed(player) then
local rank = player.status == "playing" and 2 or (player.status == "paused" and 1 or 0)
if rank > bestRank or (rank == bestRank and player.instance == currentPlayerInstance) then
best = player
bestRank = rank
end
end
end
return best
end
local function clearPlayerState()
currentPlayerInstance = ""
currentTrack = nil
currentEmbeddedLyrics = ""
lastTrackKey = ""
inFlight = nil
coverInFlight = nil
noctalia.state.set("player_instance", nil)
noctalia.state.set("player_name", nil)
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
noctalia.state.set("cover", nil)
noctalia.state.set("playing", false)
end
local function coverPathFor(track)
local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_")
return cacheDir .. "/cover_" .. safe .. ".jpg"
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function parseLRC(lrcText)
local lines = {}
for line in lrcText:gmatch("[^\n]+") do
local mins, secs = line:match("%[(%d+):(%d+%.?%d*)%]")
if mins and secs then
local ms = math.floor(tonumber(mins) * 60000 + tonumber(secs) * 1000)
local text = line:gsub("%[%d+:%d+%.?%d*%]", ""):gsub("^%s+", ""):gsub("%s+$", "")
if text ~= "" then
local isMeta = ms < 5000 and (text:find(":") or text:find(":"))
if not isMeta then
lines[#lines + 1] = { time = ms, text = text }
end
end
end
end
if #lines == 0 then return nil end
return lines
end
local function parsePlain(text)
local lines = {}
local seenLyric = false
for line in text:gmatch("[^\n]+") do
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
if trimmed ~= "" then
if seenLyric then
lines[#lines + 1] = { time = -1, text = trimmed }
elseif trimmed:find(":") or trimmed:find(":") then
-- skip metadata header line
else
seenLyric = true
lines[#lines + 1] = { time = -1, text = trimmed }
end
end
end
if #lines == 0 then return nil end
return lines
end
local function evictCache()
local keys = {}
for k, _ in pairs(cache) do keys[#keys + 1] = k end
if #keys > 30 then
table.sort(keys)
for i = 1, #keys - 30 do cache[keys[i]] = nil end
end
end
local function fetchLyricsNetEase(track, embeddedLyrics)
local tk = trackKey(track)
if inFlight == tk then return end
inFlight = tk
local function tryFetch(query, fallback)
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
noctalia.http({ url = searchUrl, headers = { "Referer: https://music.163.com" } }, function(r1)
if inFlight ~= tk then return end
if tk ~= lastTrackKey then inFlight = nil; return end
if not r1.ok or r1.status < 200 or r1.status >= 300 or not r1.body or #r1.body == 0 then
if fallback then fallback()
else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
local data = noctalia.json.decode(r1.body)
if not data or not data.result or not data.result.songs or #data.result.songs == 0 then
if fallback then fallback()
else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
local function songArtist(s)
if s.artists and #s.artists > 0 then
return (s.artists[1].name or ""):lower()
end
return ""
end
local bestMatch = nil
local trackArtist = track.artist:lower()
for _, s in ipairs(data.result.songs) do
if songArtist(s):find(trackArtist, 1, true) then
bestMatch = s
break
end
end
if not bestMatch then
bestMatch = data.result.songs[1]
end
local songId = tostring(bestMatch.id or "")
if not songId:match("^%d+$") then
if fallback then fallback()
else inFlight = nil; noctalia.state.set("lyrics", nil) end
return
end
local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. noctalia.string.urlEncode(songId) .. "&lv=1&kv=1&tv=-1"
noctalia.http({ url = lyricUrl, headers = { "Referer: https://music.163.com" } }, function(r2)
if inFlight ~= tk then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local lyrics = nil
if r2.ok and r2.status >= 200 and r2.status < 300 and r2.body and #r2.body > 0 then
local ldata = noctalia.json.decode(r2.body)
local klyricStr = ""
if ldata and ldata.klyric then
local k = ldata.klyric
if type(k) == "string" then
klyricStr = k
elseif type(k) == "table" then
klyricStr = (k.lyric and type(k.lyric) == "string") and k.lyric or ""
end
end
if klyricStr ~= "" then
local ok = pcall(noctalia.writeFile, krcTmp, klyricStr)
if not ok then klyricStr = "" end
end
if klyricStr ~= "" then
local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"'
noctalia.runAsync(py, function(r3)
if inFlight ~= tk then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "")
if ok and parsed and parsed.type == "krc" and parsed.lines then
cache[tk] = parsed.lines
evictCache()
inFlight = nil
noctalia.state.set("lyrics", parsed.lines)
return
end
local lrc = (ldata.lrc and ldata.lrc.lyric) or ""
local lyr = parseLRC(lrc)
if not lyr then lyr = parsePlain(lrc) end
if lyr then
cache[tk] = lyr
evictCache()
inFlight = nil
noctalia.state.set("lyrics", lyr)
elseif fallback then
fallback()
else
inFlight = nil
noctalia.state.set("lyrics", nil)
end
end)
return
end
if ldata and ldata.lrc and ldata.lrc.lyric and ldata.lrc.lyric ~= "" then
lyrics = parseLRC(ldata.lrc.lyric)
if not lyrics then
lyrics = parsePlain(ldata.lrc.lyric)
end
end
end
if lyrics then
cache[tk] = lyrics
evictCache()
inFlight = nil
noctalia.state.set("lyrics", lyrics)
elseif fallback then
fallback()
else
inFlight = nil
noctalia.state.set("lyrics", nil)
end
end)
end)
end
local query = track.title .. "\n" .. track.artist .. "\n" .. (track.album or "")
local qTmp = cacheDir .. "/query.tmp"
noctalia.writeFile(qTmp, query)
local dir = noctalia.pluginDir() or "/tmp"
local function applyParsed(parsed)
if parsed and parsed.type == "krc" and parsed.lines then
cache[tk] = parsed.lines
evictCache()
noctalia.state.set("lyrics", parsed.lines)
return true
end
if parsed and parsed.type == "lrc" and parsed.lrc and parsed.lrc ~= "" then
local lyr = parseLRC(parsed.lrc)
if not lyr then lyr = parsePlain(parsed.lrc) end
if lyr then
cache[tk] = lyr
evictCache()
noctalia.state.set("lyrics", lyr)
return true
end
end
return false
end
local function runPy(script, cb)
local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"'
noctalia.runAsync(py, function(r)
if inFlight ~= tk then return end
if tk ~= lastTrackKey then inFlight = nil; return end
local ok, parsed = pcall(noctalia.json.decode, r.stdout or "")
cb(ok and parsed or nil)
end)
end
local function applyText(text)
if not text or text == "" then return false end
local parsed = parseLRC(text)
if not parsed then parsed = parsePlain(text) end
if not parsed then return false end
cache[tk] = parsed
evictCache()
noctalia.state.set("lyrics", parsed)
return true
end
local function fetchCustom()
if customUrl == "" then
inFlight = nil
noctalia.state.set("lyrics", nil)
return
end
local replacements = {
title = track.title,
artist = track.artist,
album = track.album,
duration = tostring(math.floor((track.duration or 0) / 1000000)),
}
local url = customUrl:gsub("{([%w_]+)}", function(key)
return noctalia.string.urlEncode(replacements[key] or "")
end)
noctalia.http({ url = url, headers = { "Accept: application/json, text/plain" } }, function(response)
if tk ~= lastTrackKey then inFlight = nil; return end
local text = response.ok and response.body or ""
if text ~= "" and customJsonField ~= "" then
local decoded = noctalia.json.decode(text)
for part in customJsonField:gmatch("[^.]+") do
decoded = type(decoded) == "table" and decoded[part] or nil
end
if type(decoded) == "table" then
cache[tk] = decoded
evictCache()
noctalia.state.set("lyrics", decoded)
inFlight = nil
return
end
text = type(decoded) == "string" and decoded or ""
end
applyText(text)
inFlight = nil
end)
end
if lyricsSource == "external" then
inFlight = nil
return
elseif lyricsSource == "mpris" then
applyText(embeddedLyrics)
inFlight = nil
return
elseif lyricsSource == "custom" then
fetchCustom()
return
elseif lyricsSource == "netease" then
tryFetch(track.title .. " " .. track.artist, function()
tryFetch(track.title, nil)
end)
return
end
-- Auto: LRCLIB, then the public NetEase API.
runPy("lrclib_lyric.py", function(parsed)
if applyParsed(parsed) then inFlight = nil; return end
if lyricsSource == "lrclib" then
inFlight = nil
noctalia.state.set("lyrics", nil)
return
end
tryFetch(track.title .. " " .. track.artist, function()
tryFetch(track.title, nil)
end)
end)
end
local function fetchCover(track, artUrl)
local tk = trackKey(track)
if coverInFlight == tk then return end
coverInFlight = tk
local dest = coverPathFor(track)
local apply = function(path)
if coverInFlight ~= tk then return end
coverInFlight = nil
if tk ~= lastTrackKey then return end
coverCache[tk] = path
noctalia.state.set("cover", path)
end
if not artUrl or artUrl == "" then
noctalia.state.set("cover", nil)
coverInFlight = nil
return
end
if artUrl:sub(1, 7) == "file://" then
local source = noctalia.string.urlDecode(artUrl:sub(8))
if not noctalia.fileExists(source) then
apply(nil)
return
end
noctalia.runAsync("cp -- " .. shellQuote(source) .. " " .. shellQuote(dest), function(result)
if result.exitCode == 0 then apply(dest) else apply(source) end
end)
return
end
noctalia.download(artUrl, dest, function(ok)
if ok then apply(dest) else apply(nil) end
end)
end
local function poll()
local cmd = [[playerctl --all-players metadata --format $'{{playerInstance}}\x1f{{playerName}}\x1f{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]]
noctalia.runAsync(cmd, function(r)
if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
clearPlayerState()
return
end
local players = {}
local fieldSeparator = string.char(31)
local recordSeparator = string.char(30)
for record in (r.stdout .. recordSeparator):gmatch("(.-)" .. recordSeparator) do
if record ~= "" then
local parts = {}
for field in (record .. fieldSeparator):gmatch("(.-)" .. fieldSeparator) do
parts[#parts + 1] = field:gsub("^%s+", ""):gsub("%s+$", "")
end
local player = {
instance = parts[1] or "",
name = parts[2] or "",
status = parts[3] or "stopped",
title = parts[4] or "",
artist = parts[5] or "",
album = parts[6] or "",
position = tonumber(parts[7]) or 0,
duration = tonumber(parts[8]) or 0,
artUrl = parts[9] or "",
embeddedLyrics = parts[10] or "",
}
if player.instance ~= "" or player.name ~= "" then players[#players + 1] = player end
end
end
local selected = selectPlayer(players)
if not selected then
clearPlayerState()
return
end
currentPlayerInstance = selected.instance
noctalia.state.set("player_instance", selected.instance)
noctalia.state.set("player_name", selected.name)
if selected.title == "" and selected.artist == "" then
noctalia.state.set("track", nil)
noctalia.state.set("lyrics", nil)
noctalia.state.set("playing", selected.status == "playing")
return
end
local playing = selected.status == "playing"
local t = {
title = selected.title,
artist = selected.artist,
album = selected.album,
status = selected.status,
position = selected.position,
duration = selected.duration,
playerInstance = selected.instance,
}
local tk = trackKey(t)
currentTrack = t
currentEmbeddedLyrics = selected.embeddedLyrics
noctalia.state.set("position", t.position)
if tk ~= lastTrackKey then
lastTrackKey = tk
noctalia.state.set("track", t)
noctalia.state.set("playing", playing)
local cached = cache[tk]
if cached then
noctalia.state.set("lyrics", cached)
else
noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(t, selected.embeddedLyrics)
end
local cachedCover = coverCache[tk]
if cachedCover then
noctalia.state.set("cover", cachedCover)
else
noctalia.state.set("cover", nil)
fetchCover(t, selected.artUrl)
end
else
noctalia.state.set("track", t)
noctalia.state.set("playing", playing)
end
end)
end
local pollTick = 0
function update()
if lyricsSource == "external" then return end
pollTick = pollTick + 1
if pollTick % 2 == 0 then
poll()
end
end
function onConfigChanged()
local nextSource = noctalia.getConfig("lyrics_source") or "auto"
local nextUrl = noctalia.getConfig("custom_url") or ""
local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
local nextBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField
local playersChanged = table.concat(nextAllowlist, "\n") ~= table.concat(playerAllowlist, "\n")
or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n")
lyricsSource = nextSource
customUrl = nextUrl
customJsonField = nextField
playerAllowlist = nextAllowlist
playerBlocklist = nextBlocklist
if playersChanged then
currentPlayerInstance = ""
inFlight = nil
poll()
end
if sourceChanged and currentTrack and not playersChanged then
inFlight = nil
cache = {}
noctalia.state.set("lyrics", nil)
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics)
end
end
local function applyPushedLyrics(payload)
local decoded = noctalia.json.decode(payload or "")
if type(decoded) == "table" then
if decoded.track then
currentTrack = decoded.track
lastTrackKey = trackKey({
playerInstance = decoded.track.playerInstance or "",
title = decoded.track.title or "",
artist = decoded.track.artist or "",
album = decoded.track.album or "",
})
noctalia.state.set("track", decoded.track)
end
if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end
if decoded.lyrics then
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
noctalia.state.set("lyrics", parsed)
end
if decoded.position ~= nil then noctalia.state.set("position", decoded.position) end
if decoded.playing ~= nil then noctalia.state.set("playing", decoded.playing == true) end
if decoded.cover ~= nil then noctalia.state.set("cover", decoded.cover) end
return true
end
return false
end
function onIpc(event, payload)
if event == "push-lrc" then
noctalia.state.set("lyrics", parseLRC(payload or "") or parsePlain(payload or ""))
elseif event == "push-json" or event == "push-state" then
applyPushedLyrics(payload)
elseif event == "clear" then
noctalia.state.set("lyrics", nil)
end
end
if lyricsSource ~= "external" then poll() end
+201
View File
@@ -0,0 +1,201 @@
id = "h465855hgg/lyrics"
name = "Lyrics"
version = "1.3.0"
plugin_api = 3
author = "h465855hgg"
license = "MIT"
dependencies = ["playerctl", "python3", "cp"]
tags = ["bar", "service", "music", "media", "animation"]
icon = "music"
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
[[setting]]
key = "player_allowlist"
type = "string_list"
label_key = "settings.player_allowlist.label"
description_key = "settings.player_allowlist.description"
default = []
advanced = true
[[setting]]
key = "player_blocklist"
type = "string_list"
label_key = "settings.player_blocklist.label"
description_key = "settings.player_blocklist.description"
default = []
advanced = true
[[setting]]
key = "lyrics_source"
type = "select"
label_key = "settings.lyrics_source.label"
description_key = "settings.lyrics_source.description"
default = "auto"
options = [
{ value = "auto", label_key = "settings.lyrics_source.options.auto" },
{ value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" },
{ value = "netease", label_key = "settings.lyrics_source.options.netease" },
{ value = "mpris", label_key = "settings.lyrics_source.options.mpris" },
{ value = "custom", label_key = "settings.lyrics_source.options.custom" },
{ value = "external", label_key = "settings.lyrics_source.options.external" },
]
[[setting]]
key = "custom_url"
type = "string"
label_key = "settings.custom_url.label"
description_key = "settings.custom_url.description"
default = ""
visible_when = { key = "lyrics_source", values = ["custom"] }
[[setting]]
key = "custom_json_field"
type = "string"
label_key = "settings.custom_json_field.label"
description_key = "settings.custom_json_field.description"
default = "syncedLyrics"
visible_when = { key = "lyrics_source", values = ["custom"] }
[[setting]]
key = "cue_text"
type = "string"
label_key = "settings.cue_text.label"
description_key = "settings.cue_text.description"
default = "•••••"
[[setting]]
key = "cue_font_mode"
type = "select"
label_key = "settings.cue_font_mode.label"
description_key = "settings.cue_font_mode.description"
default = "follow"
options = [
{ value = "follow", label_key = "settings.cue_font_mode.options.follow" },
{ value = "custom", label_key = "settings.cue_font_mode.options.custom" },
]
[[setting]]
key = "cue_font_family"
type = "string"
label_key = "settings.cue_font_family.label"
description_key = "settings.cue_font_family.description"
default = "sans-serif"
visible_when = { key = "cue_font_mode", values = ["custom"] }
[[setting]]
key = "scroll_mode"
type = "select"
label_key = "settings.scroll_mode.label"
default = "auto"
description_key = "settings.scroll_mode.description"
options = [
{ value = "auto", label_key = "settings.scroll_mode.options.auto" },
{ value = "marquee", label_key = "settings.scroll_mode.options.marquee" },
{ value = "static", label_key = "settings.scroll_mode.options.static" },
]
[[setting]]
key = "marquee_speed"
type = "int"
label_key = "settings.marquee_speed.label"
default = 30
min = 10
max = 120
description_key = "settings.marquee_speed.description"
[[setting]]
key = "max_lines"
type = "int"
label_key = "settings.max_lines.label"
default = 1
min = 1
max = 3
description_key = "settings.max_lines.description"
[[setting]]
key = "gradient"
type = "bool"
label_key = "settings.gradient.label"
default = true
description_key = "settings.gradient.description"
[[setting]]
key = "animation"
type = "select"
label_key = "settings.animation.label"
default = "karaoke"
description_key = "settings.animation.description"
options = [
{ value = "karaoke", label_key = "settings.animation.options.karaoke" },
{ value = "cascade", label_key = "settings.animation.options.cascade" },
{ value = "wave", label_key = "settings.animation.options.wave" },
{ value = "fade", label_key = "settings.animation.options.fade" },
{ value = "none", label_key = "settings.animation.options.none" },
]
[[setting]]
key = "max_chars"
type = "int"
label_key = "settings.max_chars.label"
default = 15
min = 8
max = 80
description_key = "settings.max_chars.description"
[[setting]]
key = "char_width"
type = "int"
label_key = "settings.char_width.label"
default = 9
min = 4
max = 24
description_key = "settings.char_width.description"
# Headless background service: polls MPRIS metadata, fetches lyrics, publishes state.
[[service]]
id = "service"
entry = "lyrics_service.luau"
# Bar widget: thin presentation that mirrors the published lyrics state.
[[widget]]
id = "lyrics"
entry = "lyrics.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
default = "music"
[[widget.setting]]
key = "show_artist"
type = "bool"
label_key = "settings.show_artist.label"
default = true
[[widget.setting]]
key = "hide_when_paused"
type = "bool"
label_key = "settings.hide_when_paused.label"
default = false
[[widget.setting]]
key = "show_cover"
type = "bool"
label_key = "settings.show_cover.label"
description_key = "settings.show_cover.description"
default = true
[[widget.setting]]
key = "active_color"
type = "color"
label_key = "settings.active_color.label"
description_key = "settings.active_color.description"
default = "on_surface"
[[widget.setting]]
key = "inactive_color"
type = "color"
label_key = "settings.inactive_color.label"
description_key = "settings.inactive_color.description"
default = "on_surface_variant"
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env sh
set -eu
usage() {
cat <<'EOF'
Usage: scripts/setup-deps.sh [--check] [--yes]
Install or check runtime dependencies for the Noctalia Lyrics plugin.
Options:
--check Only report missing commands; do not install anything.
--yes Skip the confirmation prompt before installing packages.
--help Show this help text.
EOF
}
CHECK_ONLY=0
ASSUME_YES=0
while [ "$#" -gt 0 ]; do
case "$1" in
--check)
CHECK_ONLY=1
;;
-y|--yes)
ASSUME_YES=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
need_command() {
command -v "$1" >/dev/null 2>&1 || MISSING_COMMANDS="$MISSING_COMMANDS $1"
}
MISSING_COMMANDS=""
need_command playerctl
need_command python3
need_command cp
if [ -z "$MISSING_COMMANDS" ]; then
echo "All runtime commands are installed: playerctl python3 cp"
exit 0
fi
echo "Missing runtime command(s):$MISSING_COMMANDS"
if [ "$CHECK_ONLY" -eq 1 ]; then
exit 1
fi
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
elif command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
echo "sudo is required to install packages as a non-root user." >&2
exit 1
fi
detect_pm() {
if command -v apt-get >/dev/null 2>&1; then
echo apt
elif command -v dnf >/dev/null 2>&1; then
echo dnf
elif command -v pacman >/dev/null 2>&1; then
echo pacman
elif command -v zypper >/dev/null 2>&1; then
echo zypper
elif command -v apk >/dev/null 2>&1; then
echo apk
elif command -v xbps-install >/dev/null 2>&1; then
echo xbps
else
echo unknown
fi
}
PM="$(detect_pm)"
case "$PM" in
apt)
INSTALL_CMD="$SUDO apt-get update && $SUDO apt-get install -y playerctl python3 coreutils"
;;
dnf)
INSTALL_CMD="$SUDO dnf install -y playerctl python3 coreutils"
;;
pacman)
INSTALL_CMD="$SUDO pacman -S --needed playerctl python coreutils"
;;
zypper)
INSTALL_CMD="$SUDO zypper install -y playerctl python3 coreutils"
;;
apk)
INSTALL_CMD="$SUDO apk add playerctl python3 coreutils"
;;
xbps)
INSTALL_CMD="$SUDO xbps-install -Sy playerctl python3 coreutils"
;;
*)
cat >&2 <<'EOF'
Could not detect a supported package manager.
Install these packages manually with your distribution package manager:
playerctl python3 coreutils
EOF
exit 1
;;
esac
echo "Detected package manager: $PM"
echo "Install command: $INSTALL_CMD"
if [ "$ASSUME_YES" -ne 1 ]; then
printf "Proceed with installation? [y/N] "
read -r answer
case "$answer" in
y|Y|yes|YES)
;;
*)
echo "Cancelled."
exit 1
;;
esac
fi
sh -c "$INSTALL_CMD"
MISSING_COMMANDS=""
need_command playerctl
need_command python3
need_command cp
if [ -n "$MISSING_COMMANDS" ]; then
echo "Still missing after installation:$MISSING_COMMANDS" >&2
exit 1
fi
echo "Dependencies installed successfully."
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+113
View File
@@ -0,0 +1,113 @@
{
"instrumental": "Instrumental",
"intro": "Intro",
"no_lyrics": "♪ No lyrics available",
"settings": {
"active_color": {
"description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color.",
"label": "Active lyric color"
},
"animation": {
"description": "How lyrics animate on line change.",
"label": "Animation",
"options": {
"cascade": "Character cascade",
"fade": "Fade only",
"karaoke": "Karaoke gradient + fade",
"none": "No animation",
"wave": "Character wave reveal"
}
},
"char_width": {
"description": "Approximate pixel width per character, used to fix the widget width and clip scrolling.",
"label": "Character width (px)"
},
"cue_font_family": {
"description": "Installed font family used only for intro and interlude characters in custom-font mode.",
"label": "Intro/interlude font"
},
"cue_font_mode": {
"description": "Choose whether intro and interlude characters follow Noctalia's interface font or use another installed font.",
"label": "Intro/interlude font mode",
"options": {
"custom": "Custom font",
"follow": "Follow interface font"
}
},
"cue_text": {
"description": "Text or characters highlighted during intros and interludes.",
"label": "Intro/interlude characters"
},
"custom_json_field": {
"description": "Lyrics field in a JSON response, with dotted paths such as data.lyric. Leave empty for plain LRC.",
"label": "JSON lyrics field"
},
"custom_url": {
"description": "Supports {title}, {artist}, {album}, and {duration} placeholders. Text responses may contain LRC directly.",
"label": "Custom endpoint URL"
},
"glyph": {
"label": "Glyph"
},
"gradient": {
"description": "Light up each character as the song progresses (karaoke style).",
"label": "Per-character gradient"
},
"hide_when_paused": {
"label": "Hide when paused"
},
"inactive_color": {
"description": "Color used for upcoming lyrics, paused playback, and secondary lines.",
"label": "Inactive lyric color"
},
"lyrics_source": {
"description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.",
"label": "Lyrics source",
"options": {
"auto": "Automatic fallback",
"custom": "Custom HTTP endpoint",
"external": "External IPC push",
"lrclib": "LRCLIB",
"mpris": "Local player (MPRIS)",
"netease": "NetEase Music (public API)"
}
},
"marquee_speed": {
"description": "Pixels per second for long-line scrolling.",
"label": "Marquee speed"
},
"max_chars": {
"description": "Visible width before a long lyric line scrolls (marquee).",
"label": "Max characters"
},
"max_lines": {
"description": "Maximum lyric lines to show at once.",
"label": "Max lines"
},
"player_allowlist": {
"description": "Only use matching MPRIS player names or instances. Supports * wildcards; leave empty to allow all players.",
"label": "Allowed players"
},
"player_blocklist": {
"description": "Ignore matching MPRIS player names or instances. Supports * wildcards and takes priority over the allowlist.",
"label": "Blocked players"
},
"scroll_mode": {
"description": "How lyrics scroll in the bar.",
"label": "Scroll mode",
"options": {
"auto": "Auto (synced)",
"marquee": "Marquee",
"static": "Static"
}
},
"show_artist": {
"label": "Show artist"
},
"show_cover": {
"description": "Display the song's album art next to the lyrics.",
"label": "Show album cover"
}
},
"title": "Lyrics"
}
+1
View File
@@ -26,3 +26,4 @@ MangoWC changes keymode; click it to show the current keymode in a notification.
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `show_text` | `bool` | `true` | Shows the current keymode beside the widget glyph. | | `show_text` | `bool` | `true` | Shows the current keymode beside the widget glyph. |
| `notify_change` | `bool` | `true` | Sends a notification when the keymode changes. | | `notify_change` | `bool` | `true` | Sends a notification when the keymode changes. |
| `hide_on_default` | `bool` | `false` | Hides the widget when default keymode is active. |
+10 -4
View File
@@ -1,5 +1,6 @@
local showText = noctalia.getConfig("show_text") local showText = noctalia.getConfig("show_text")
local notifyChange = noctalia.getConfig("notify_change") local notifyChange = noctalia.getConfig("notify_change")
local hideOnDefault = noctalia.getConfig("hide_on_default")
local currentKeymode = "default" local currentKeymode = "default"
@@ -17,11 +18,16 @@ end)
function update() function update()
noctalia.setUpdateInterval(1000) noctalia.setUpdateInterval(1000)
barWidget.setGlyph("keyboard") if hideOnDefault and currentKeymode == "default" then
if showText then barWidget.setVisible(false)
barWidget.setText(currentKeymode)
else else
barWidget.setText("") barWidget.setVisible(true)
barWidget.setGlyph("keyboard")
if showText then
barWidget.setText(currentKeymode)
else
barWidget.setText("")
end
end end
end end
+7 -1
View File
@@ -1,6 +1,6 @@
id = "gambled23/mangowm-keymode" id = "gambled23/mangowm-keymode"
name = "Mangowm Keymode" name = "Mangowm Keymode"
version = "1.0.1" version = "1.0.2"
plugin_api = 3 plugin_api = 3
author = "gambled23" author = "gambled23"
license = "MIT" license = "MIT"
@@ -14,6 +14,12 @@ tags = ["bar", "mangowc"]
id = "mangowm-keymode" id = "mangowm-keymode"
entry = "keymode.luau" entry = "keymode.luau"
[[widget.setting]]
key = "hide_on_default"
type = "bool"
label_key = "settings.hide_on_default.label"
default = false
[[widget.setting]] [[widget.setting]]
key = "show_text" key = "show_text"
type = "bool" type = "bool"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+11 -2
View File
@@ -1,4 +1,13 @@
{ {
"settings.show_text.label": "Show text on bar", "settings": {
"settings.notify_change.label": "Notify change" "hide_on_default": {
"label": "Hide on default keymode"
},
"notify_change": {
"label": "Notify change"
},
"show_text": {
"label": "Show text on bar"
}
}
} }
+102 -102
View File
@@ -1,116 +1,116 @@
{ {
"title": "Mini Docker", "actions": {
"widget": { "cancel": "Cancel",
"running": "Running containers: {count}", "close": "Close",
"unavailable": "Docker is not available", "refresh": "Refresh",
"refresh_requested": "Docker refresh requested" "remove": "Remove",
}, "restart": "Restart",
"settings": { "run": "Run",
"refresh_interval": { "select": "Select",
"label": "Refresh interval", "start": "Start",
"description": "How often Mini Docker refreshes Docker state, in seconds." "stop": "Stop"
},
"default_network": {
"label": "Default network",
"description": "Network selected when running an image."
},
"show_count": {
"label": "Show running count",
"description": "Show the number of running containers beside the Docker icon."
},
"glyph_color": {
"label": "Icon color",
"description": "Theme color used by the Docker icon."
},
"status_mode": {
"label": "Status indicator",
"description": "Choose when the status dot is visible.",
"options": {
"always": "Always",
"running_only": "Only while containers are running",
"hidden": "Hidden"
}
},
"active_color": {
"label": "Active indicator color",
"description": "Color used when at least one container is running."
},
"inactive_color": {
"label": "Inactive indicator color",
"description": "Color used when no containers are running."
}
}, },
"colors": { "colors": {
"default": "Default", "default": "Default",
"error": "Error",
"muted": "Muted",
"primary": "Primary", "primary": "Primary",
"secondary": "Secondary", "secondary": "Secondary",
"tertiary": "Tertiary",
"success": "Success", "success": "Success",
"error": "Error", "tertiary": "Tertiary"
"muted": "Muted" },
"details": {
"created": "Created: {value}",
"driver": "Driver: {value}",
"id": "ID: {value}",
"image": "Image: {value}",
"mountpoint": "Mountpoint: {value}",
"ports": "Ports: {value}",
"scope": "Scope: {value}",
"size": "Size: {value}",
"status": "Status: {value}"
},
"panel": {
"busy": "Docker command in progress…",
"default_network": "Built-in network",
"image_in_use": "Used by a container",
"image_unused": "Not used by a container",
"loading": "Loading Docker state…",
"no_containers": "No containers found.",
"no_images": "No images found.",
"no_networks": "No networks found.",
"no_volumes": "No volumes found.",
"select_hint": "Select an item to see available actions.",
"showing_limit": "Showing the first {count} items.",
"updated": "Last updated: {time}"
},
"result": {
"command_busy": "Another Docker command is still running.",
"docker_missing": "The Docker CLI is not installed.",
"docker_unreachable": "Could not reach the Docker daemon.",
"failed": "Docker command failed: {error}",
"success": "Docker command completed."
},
"run_form": {
"container_name": "Container name (optional)",
"container_name_placeholder": "my-container",
"environment": "Environment variables",
"environment_help": "Enter one KEY=value pair per line.",
"environment_placeholder": "KEY=value\nANOTHER_KEY=value",
"invalid_environment": "Invalid environment-variable line: {line}",
"invalid_name": "Container names may contain letters, numbers, dots, underscores, and hyphens.",
"invalid_port": "Port must be a number from 1 to 65535.",
"network": "Network",
"port": "Host and container port",
"port_placeholder": "8080",
"publish_port": "Publish a port",
"title": "Run {image}"
},
"settings": {
"active_color": {
"description": "Color used when at least one container is running.",
"label": "Active indicator color"
},
"default_network": {
"description": "Network selected when running an image.",
"label": "Default network"
},
"glyph_color": {
"description": "Theme color used by the Docker icon.",
"label": "Icon color"
},
"inactive_color": {
"description": "Color used when no containers are running.",
"label": "Inactive indicator color"
},
"refresh_interval": {
"description": "How often Mini Docker refreshes Docker state, in seconds.",
"label": "Refresh interval"
},
"show_count": {
"description": "Show the number of running containers beside the Docker icon.",
"label": "Show running count"
},
"status_mode": {
"description": "Choose when the status dot is visible.",
"label": "Status indicator",
"options": {
"always": "Always",
"hidden": "Hidden",
"running_only": "Only while containers are running"
}
}
}, },
"tabs": { "tabs": {
"containers": "Containers", "containers": "Containers",
"images": "Images", "images": "Images",
"volumes": "Volumes", "networks": "Networks",
"networks": "Networks" "volumes": "Volumes"
}, },
"actions": { "title": "Mini Docker",
"refresh": "Refresh", "widget": {
"select": "Select", "refresh_requested": "Docker refresh requested",
"start": "Start", "running": "Running containers: {count}",
"stop": "Stop", "unavailable": "Docker is not available"
"restart": "Restart",
"remove": "Remove",
"run": "Run",
"cancel": "Cancel",
"close": "Close"
},
"panel": {
"loading": "Loading Docker state…",
"busy": "Docker command in progress…",
"updated": "Last updated: {time}",
"select_hint": "Select an item to see available actions.",
"no_containers": "No containers found.",
"no_images": "No images found.",
"no_volumes": "No volumes found.",
"no_networks": "No networks found.",
"showing_limit": "Showing the first {count} items.",
"default_network": "Built-in network",
"image_in_use": "Used by a container",
"image_unused": "Not used by a container"
},
"details": {
"image": "Image: {value}",
"status": "Status: {value}",
"ports": "Ports: {value}",
"id": "ID: {value}",
"created": "Created: {value}",
"size": "Size: {value}",
"driver": "Driver: {value}",
"mountpoint": "Mountpoint: {value}",
"scope": "Scope: {value}"
},
"run_form": {
"title": "Run {image}",
"container_name": "Container name (optional)",
"container_name_placeholder": "my-container",
"network": "Network",
"publish_port": "Publish a port",
"port": "Host and container port",
"port_placeholder": "8080",
"environment": "Environment variables",
"environment_placeholder": "KEY=value\nANOTHER_KEY=value",
"environment_help": "Enter one KEY=value pair per line.",
"invalid_name": "Container names may contain letters, numbers, dots, underscores, and hyphens.",
"invalid_port": "Port must be a number from 1 to 65535.",
"invalid_environment": "Invalid environment-variable line: {line}"
},
"result": {
"success": "Docker command completed.",
"failed": "Docker command failed: {error}",
"docker_missing": "The Docker CLI is not installed.",
"docker_unreachable": "Could not reach the Docker daemon.",
"command_busy": "Another Docker command is still running."
} }
} }
+154 -82
View File
@@ -1,84 +1,156 @@
{ {
"bar.checking": "Checking...", "bar": {
"bar.update_available": "Update available!", "checking": "Checking...",
"bar.up_to_date": "Up to date", "unknown": "Unknown",
"bar.unknown": "Unknown", "up_to_date": "Up to date",
"notification.checking_update": "Checking NixOS update...", "update_available": "Update available!"
"notification.update_is_available": "NixOS update is available!", },
"notification.update_check_completed": "NixOS update check completed!", "notification": {
"notification.update_command_is_empty": "Update command is empty!", "checking_update": "Checking NixOS update...",
"notification.optimize_command_is_empty": "Optimize command is empty!", "clean_command_is_empty": "Clean command is empty!",
"notification.clean_command_is_empty": "Clean command is empty!", "optimize_command_is_empty": "Optimize command is empty!",
"panel.title": "Nix Monitor", "update_check_completed": "NixOS update check completed!",
"panel.cancel": "Cancel", "update_command_is_empty": "Update command is empty!",
"panel.update": "Update", "update_is_available": "NixOS update is available!"
"panel.check": "Check", },
"panel.optimize": "Optimize", "panel": {
"panel.clean": "Clean", "cancel": "Cancel",
"panel.local": "Local", "check": "Check",
"panel.remote": "Remote", "optimize": "Optimize",
"panel.last_checked": "Last checked", "clean": "Clean",
"panel.current": "Current", "closure_size": "Closure size",
"panel.store_size": "Store size", "current": "Current",
"panel.closure_size": "Closure size", "last_checked": "Last checked",
"terminal.press_enter_to_exit": "Press enter to exit...", "local": "Local",
"setting.widget.show_text.label": "Show text", "remote": "Remote",
"setting.widget.colorize_text.label": "Colorize text", "store_size": "Store size",
"setting.widget.show_glyph.label": "Show glyph", "title": "Nix Monitor",
"setting.widget.colorize_glyph.label": "Colorize glyph", "update": "Update"
"setting.widget.up_to_date_glyph.label": "Up to date glyph", },
"setting.widget.up_to_date_color.label": "Up to date color", "setting": {
"setting.widget.checking_glyph.label": "Checking glyph", "branch": {
"setting.widget.checking_color.label": "Checking color", "description": "Which nixpkgs branch to use",
"setting.widget.update_available_glyph.label": "Update available glyph", "label": "Branch"
"setting.widget.update_available_color.label": "Update available color", },
"setting.widget.unknown_glyph.label": "Unknown status glyph", "update_command": {
"setting.widget.unknown_color.label": "Unknown status color", "description": "Command to run when clicking Update button",
"setting.widget.panel_card_background_color.label": "Panel card background color", "label": "Update command"
"setting.widget.panel_card_background_opacity.label": "Panel card background opacity", },
"setting.update_check_interval.label": "Update check interval", "optimize_command": {
"setting.update_check_interval.description": "How often to check for updates (in minutes)", "label": "Optimize command",
"setting.update_check_duration_threshold.label": "Update check duration threshold", "description": "Command to run when clicking Optimize button"
"setting.update_check_duration_threshold.description": "Cancel update check if it took too long (in minutes)", },
"setting.system_stats_check_interval.label": "System stats check interval", "clean_command": {
"setting.system_stats_check_interval.description": "How often to check for system stats (in minutes)", "description": "Command to run when clicking Clean button",
"setting.system_stats_check_duration_threshold.label": "System stats check duration threshold", "label": "Clean command"
"setting.system_stats_check_duration_threshold.description": "Cancel system stats check if it took too long (in minutes)", },
"setting.show_update_check_notification.label": "Show update check notification", "close_on_enter": {
"setting.show_update_check_notification.description": "Whether to show a notification whenever update check is in progress and completed", "description": "Add 'Press enter to exit' on the terminal window",
"setting.show_update_available_notification.label": "Show update available notification", "label": "Close on press Enter"
"setting.show_update_available_notification.description": "Whether to show a notification whenever an update is available", },
"setting.branch.label": "Branch", "hide_update_button": {
"setting.branch.description": "Which nixpkgs branch to use", "description": "Whether to hide the update button on the panel",
"setting.update_command.label": "Update command", "label": "Hide update button"
"setting.update_command.description": "Command to run when clicking Update button", },
"setting.optimize_command.label": "Optimize command", "hide_optimize_button": {
"setting.optimize_command.description": "Command to run when clicking Optimize button", "label": "Hide optimize button",
"setting.clean_command.label": "Clean command", "description": "Whether to hide the optimize button on the panel"
"setting.clean_command.description": "Command to run when clicking Clean button", },
"setting.hide_update_button.label": "Hide update button", "hide_clean_button": {
"setting.hide_update_button.description": "Whether to hide the update button on the panel", "description": "Whether to hide the clean button on the panel",
"setting.hide_optimize_button.label": "Hide optimize button", "label": "Hide clean button"
"setting.hide_optimize_button.description": "Whether to hide the optimize button on the panel", },
"setting.hide_clean_button.label": "Hide clean button", "option": {
"setting.hide_clean_button.description": "Whether to hide the clean button on the panel", "branch": {
"setting.close_on_enter.label": "Close on press Enter", "master": "Master",
"setting.close_on_enter.description": "Add 'Press enter to exit' on the terminal window", "nixos_23_05": "NixOS 23.05",
"setting.option.branch.master": "Master", "nixos_23_05_small": "NixOS 23.05 Small",
"setting.option.branch.nixos_unstable": "NixOS Unstable", "nixos_23_11": "NixOS 23.11",
"setting.option.branch.nixos_unstable_small": "NixOS Unstable Small", "nixos_23_11_small": "NixOS 23.11 Small",
"setting.option.branch.nixos_26_05": "NixOS 26.05", "nixos_24_05": "NixOS 24.05",
"setting.option.branch.nixos_25_11": "NixOS 25.11", "nixos_24_05_small": "NixOS 24.05 Small",
"setting.option.branch.nixos_25_05": "NixOS 25.05", "nixos_24_11": "NixOS 24.11",
"setting.option.branch.nixos_24_11": "NixOS 24.11", "nixos_24_11_small": "NixOS 24.11 Small",
"setting.option.branch.nixos_24_05": "NixOS 24.05", "nixos_25_05": "NixOS 25.05",
"setting.option.branch.nixos_23_11": "NixOS 23.11", "nixos_25_05_small": "NixOS 25.05 Small",
"setting.option.branch.nixos_23_05": "NixOS 23.05", "nixos_25_11": "NixOS 25.11",
"setting.option.branch.nixos_26_05_small": "NixOS 26.05 Small", "nixos_25_11_small": "NixOS 25.11 Small",
"setting.option.branch.nixos_25_11_small": "NixOS 25.11 Small", "nixos_26_05": "NixOS 26.05",
"setting.option.branch.nixos_25_05_small": "NixOS 25.05 Small", "nixos_26_05_small": "NixOS 26.05 Small",
"setting.option.branch.nixos_24_11_small": "NixOS 24.11 Small", "nixos_unstable": "NixOS Unstable",
"setting.option.branch.nixos_24_05_small": "NixOS 24.05 Small", "nixos_unstable_small": "NixOS Unstable Small"
"setting.option.branch.nixos_23_11_small": "NixOS 23.11 Small", }
"setting.option.branch.nixos_23_05_small": "NixOS 23.05 Small" },
"show_update_available_notification": {
"description": "Whether to show a notification whenever an update is available",
"label": "Show update available notification"
},
"show_update_check_notification": {
"description": "Whether to show a notification whenever update check is in progress and completed",
"label": "Show update check notification"
},
"system_stats_check_duration_threshold": {
"description": "Cancel system stats check if it took too long (in minutes)",
"label": "System stats check duration threshold"
},
"system_stats_check_interval": {
"description": "How often to check for system stats (in minutes)",
"label": "System stats check interval"
},
"update_check_duration_threshold": {
"description": "Cancel update check if it took too long (in minutes)",
"label": "Update check duration threshold"
},
"update_check_interval": {
"description": "How often to check for updates (in minutes)",
"label": "Update check interval"
},
"widget": {
"checking_color": {
"label": "Checking color"
},
"checking_glyph": {
"label": "Checking glyph"
},
"colorize_glyph": {
"label": "Colorize glyph"
},
"colorize_text": {
"label": "Colorize text"
},
"panel_card_background_color": {
"label": "Panel card background color"
},
"panel_card_background_opacity": {
"label": "Panel card background opacity"
},
"show_glyph": {
"label": "Show glyph"
},
"show_text": {
"label": "Show text"
},
"unknown_color": {
"label": "Unknown status color"
},
"unknown_glyph": {
"label": "Unknown status glyph"
},
"up_to_date_color": {
"label": "Up to date color"
},
"up_to_date_glyph": {
"label": "Up to date glyph"
},
"update_available_color": {
"label": "Update available color"
},
"update_available_glyph": {
"label": "Update available glyph"
}
}
},
"terminal": {
"press_enter_to_exit": "Press enter to exit..."
}
} }
+72
View File
@@ -0,0 +1,72 @@
# noctwhspr
Noctalia companion for [hyprwhspr](https://github.com/goodroot/hyprwhspr) —
native speech-to-text for Linux. hyprwhspr is fast, accurate, private,
system-wide dictation: press a hotkey, talk, and your words land in whatever
you were typing — transcribed by local in-memory models (Whisper, Parakeet)
that never leave your machine, or by top cloud APIs if you point it there.
Its visuals even theme themselves to match your Noctalia theme.
This plugin puts hyprwhspr on your bar: the widget shows the dictation state
at a glance, left-click starts or stops a recording, and right-click restarts
the hyprwhspr service if it ever gets stuck.
## Plugin
| Field | Value |
| --- | --- |
| ID | `goodroot/noctwhspr` |
| Entries | Bar widget: `status` |
## Requirements
Install [`hyprwhspr`](https://github.com/goodroot/hyprwhspr) and set it up so
its `hyprwhspr.service` systemd user service exists. The widget drives
hyprwhspr's own tray script (`hyprwhspr-tray.sh`), which it looks up under the
install root — `/usr/lib/hyprwhspr` for the Arch/AUR package. Nothing else is
needed; the widget has no network access of its own.
## Usage
Enable the plugin in Settings → Plugins, then add the `status` widget to your
bar in Settings → Bar.
The glyph tracks the dictation state reported by hyprwhspr:
| Glyph | State |
| --- | --- |
| Filled record dot (error color) | Recording — dictation is capturing audio |
| Microphone (primary color) | Ready — service is up, waiting for a hotkey or click |
| Zzz | Model unloaded — first recording will load it |
| Microphone off | Service stopped |
| Warning triangle | Error — the tooltip explains what is wrong |
Interactions:
- **Left-click** — toggle recording (same as the hyprwhspr hotkey). If the
service is stopped, this starts it first.
- **Right-click** — restart the `hyprwhspr.service` systemd user service.
- **Hover** — the tooltip shows the detailed status line, including error
reasons when something is wrong.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `root` | `string` | `/usr/lib/hyprwhspr` | Directory hyprwhspr is installed under. Leave at the default unless hyprwhspr lives at a non-standard prefix; `HYPRWHSPR_ROOT` in Noctalia's environment is also honored. |
## Notes
- The widget is a thin shim over hyprwhspr's tray script — the same script
that backs its Waybar module — so state detection stays in one place. It
polls `<root>/config/hyprland/hyprwhspr-tray.sh status` every 2 seconds and
renders the returned JSON.
- Spawned processes: the tray script on every poll and click; the script in
turn queries `systemctl --user` and hyprwhspr's runtime state files.
Right-click runs `systemctl --user restart hyprwhspr.service` through the
script. No network calls and no filesystem writes are made by the plugin
itself.
- If the widget shows "hyprwhspr not found", hyprwhspr is not installed at
the configured root — point the `root` setting at your install prefix.
- Works on any compositor Noctalia runs on; the `hyprland` in the script path
is historical, hyprwhspr itself supports Hyprland, Niri and friends.
+21
View File
@@ -0,0 +1,21 @@
id = "goodroot/noctwhspr"
name = "noctwhspr"
version = "1.0.0"
plugin_api = 3
author = "goodroot"
license = "MIT"
icon = "microphone"
description = "Noctalia companion for hyprwhspr: shows dictation state, click to record, right-click to restart."
dependencies = ["hyprwhspr"]
tags = ["audio", "bar", "recording", "utility"]
[[widget]]
id = "status"
entry = "widget.luau"
[[widget.setting]]
key = "root"
type = "string"
label_key = "settings.root.label"
description_key = "settings.root.description"
default = "/usr/lib/hyprwhspr"
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+9
View File
@@ -0,0 +1,9 @@
{
"settings": {
"root": {
"description": "Directory hyprwhspr is installed under. Leave at the default unless hyprwhspr lives at a non-standard prefix.",
"label": "hyprwhspr install root"
}
},
"title": "noctwhspr"
}
+125
View File
@@ -0,0 +1,125 @@
--!nonstrict
-- hyprwhspr bar widget for Noctalia.
--
-- Thin shim over the existing tray script (single source of truth for state
-- detection, shared with the Waybar module): polls `hyprwhspr-tray.sh status`
-- and renders its JSON (class + tooltip) as a glyph. Clicks invoke the same
-- script actions the Waybar module binds.
local TRAY_REL = "/config/hyprland/hyprwhspr-tray.sh"
-- state class (from the tray script's JSON) -> glyph + palette color
local STATES = {
recording = { glyph = "player-record-filled", color = "error" },
ready = { glyph = "microphone", color = "primary" },
unloaded = { glyph = "zzz", color = "outline" },
stopped = { glyph = "microphone-off", color = "outline" },
error = { glyph = "alert-triangle", color = "error" },
}
local function applyState(state, tooltip)
barWidget.setGlyph(state.glyph)
barWidget.setGlyphColor(state.color)
if tooltip ~= nil and tooltip ~= "" then
barWidget.setTooltip(tooltip)
end
end
local function applyError(tooltip)
applyState(STATES.error, tooltip)
end
-- Resolve the tray script lazily and cache the hit. Noctalia's process is
-- spawned by the compositor, so HYPRWHSPR_ROOT is usually absent from its
-- environment — the widget "root" setting is the override for non-standard
-- install prefixes.
local trayCached = nil
local function trayPath()
if trayCached ~= nil and noctalia.fileExists(trayCached) then
return trayCached
end
trayCached = nil
local candidates = {}
local cfg = noctalia.getConfig("root")
if type(cfg) == "string" and cfg ~= "" then
table.insert(candidates, cfg)
end
local env = noctalia.getenv("HYPRWHSPR_ROOT")
if env ~= nil and env ~= "" then
table.insert(candidates, env)
end
table.insert(candidates, "/usr/lib/hyprwhspr")
for _, root in ipairs(candidates) do
local path = root .. TRAY_REL
if noctalia.fileExists(path) then
trayCached = path
return path
end
end
return nil
end
local function trayCommand(action)
local path = trayPath()
if path == nil then
return nil
end
-- Single-quote the path: install roots may contain spaces.
return "'" .. path .. "' " .. action
end
local function render(json)
if type(json) ~= "string" or json == "" then
applyError("hyprwhspr: tray script returned no status")
return
end
local class = json:match('"class":"([^"]*)"') or "error"
local tooltip = json:match('"tooltip":"([^"]*)"') or ""
-- Strip the Waybar cache-buster line, unescape \n
tooltip = tooltip:gsub("\\n_ts:%d+", "")
tooltip = tooltip:gsub("\\n", "\n")
applyState(STATES[class] or STATES.error, tooltip)
end
local function refresh()
local cmd = trayCommand("status")
if cmd == nil then
applyError("hyprwhspr not found\nInstall hyprwhspr or set this widget's root setting")
return
end
noctalia.runAsync(cmd, function(result)
if type(result) ~= "table" then
applyError("hyprwhspr: status query failed")
return
end
render(result.stdout)
end)
end
function update()
-- The tray script re-derives state via many subprocesses per call; 2s is
-- responsive enough (the recording overlay gives immediate feedback).
noctalia.setUpdateInterval(2000)
refresh()
end
function onClick()
local cmd = trayCommand("record")
if cmd ~= nil then
noctalia.runAsync(cmd, function() refresh() end)
end
end
function onRightClick()
local cmd = trayCommand("restart")
if cmd ~= nil then
noctalia.runAsync(cmd, function() refresh() end)
end
end
-- Honest placeholder until the first poll lands.
barWidget.setGlyph("microphone")
barWidget.setGlyphColor("outline")
barWidget.setTooltip("hyprwhspr: waiting for status…")
+1 -1
View File
@@ -277,7 +277,7 @@ local function catSection(catName, ports)
fill = "surface_container/0.4", fill = "surface_container/0.4",
}, { }, {
ui.glyph({ name = meta.icon, size = 12, color = meta.color }), ui.glyph({ name = meta.icon, size = 12, color = meta.color }),
ui.label({ text = noctalia.tr("categories." .. catName), fontSize = 11, fontWeight = "bold", color = meta.color }), ui.label({ text = noctalia.tr("categories." .. catName:lower()), fontSize = 11, fontWeight = "bold", color = meta.color }),
ui.label({ text = tostring(#ports), fontSize = 10, color = meta.color .. "/0.6" }), ui.label({ text = tostring(#ports), fontSize = 10, color = meta.color .. "/0.6" }),
})) }))
+1 -1
View File
@@ -2,7 +2,7 @@
id = "rxtsel/portctl" id = "rxtsel/portctl"
name = "Portctl" name = "Portctl"
version = "0.2.0" version = "0.2.1"
plugin_api = 3 plugin_api = 3
author = "Cristhian Melo" author = "Cristhian Melo"
license = "MIT" license = "MIT"
+38 -38
View File
@@ -1,54 +1,54 @@
{ {
"title": "Portctl", "categories": {
"cloud": "Cloud",
"containers": "Containers",
"databases": "Databases",
"development": "Development",
"other": "Other",
"servers": "Servers"
},
"panel": { "panel": {
"search_placeholder": "Search port, process, PID…",
"tcp": "TCP",
"udp": "UDP",
"no_ports": "No listening ports",
"no_results": "No results for \"{query}\"",
"killing": "Killing {name}…",
"kill_confirm": "Kill {name}?",
"cancel": "Cancel", "cancel": "Cancel",
"kill": "Kill", "kill": "Kill",
"pid_copied": "Copied PID {pid}" "kill_confirm": "Kill {name}?",
}, "killing": "Killing {name}…",
"categories": { "no_ports": "No listening ports",
"Development": "Development", "no_results": "No results for \"{query}\"",
"Databases": "Databases", "pid_copied": "Copied PID {pid}",
"Containers": "Containers", "search_placeholder": "Search port, process, PID…",
"Servers": "Servers", "tcp": "TCP",
"Cloud": "Cloud", "udp": "UDP"
"Other": "Other"
}, },
"service": { "service": {
"terminated": "Process {pid} terminated",
"kill_failed": "Failed to kill PID {pid}", "kill_failed": "Failed to kill PID {pid}",
"ss_not_found": "ss (iproute2) not found — install the iproute2 package" "ss_not_found": "ss (iproute2) not found — install the iproute2 package",
"terminated": "Process {pid} terminated"
}, },
"settings": { "settings": {
"refresh_interval": { "glyph": {
"label": "Refresh interval", "description": "Icon shown in the bar.",
"description": "Seconds between port scans" "label": "Glyph"
},
"ignore_list": {
"label": "Ignore processes",
"description": "Comma-separated process name substrings to hide (e.g. discord,chrome,steam)"
},
"ignore_ports": {
"label": "Ignore ports",
"description": "Comma-separated port numbers to hide (e.g. 37700,6463)"
}, },
"hide_system_ports": { "hide_system_ports": {
"label": "Hide system ports", "description": "Hide ports below 1024 (privileged/root ports)",
"description": "Hide ports below 1024 (privileged/root ports)" "label": "Hide system ports"
}, },
"hide_unknown_ports": { "hide_unknown_ports": {
"label": "Hide unknown ports", "description": "Hide ports whose process info is not accessible (e.g. rootlessport, root-owned processes without sudo)",
"description": "Hide ports whose process info is not accessible (e.g. rootlessport, root-owned processes without sudo)" "label": "Hide unknown ports"
}, },
"glyph": { "ignore_list": {
"label": "Glyph", "description": "Comma-separated process name substrings to hide (e.g. discord,chrome,steam)",
"description": "Icon shown in the bar." "label": "Ignore processes"
},
"ignore_ports": {
"description": "Comma-separated port numbers to hide (e.g. 37700,6463)",
"label": "Ignore ports"
},
"refresh_interval": {
"description": "Seconds between port scans",
"label": "Refresh interval"
} }
} },
"title": "Portctl"
} }
+9 -2
View File
@@ -1,13 +1,13 @@
id = "radimous/prismlauncher-instances" id = "radimous/prismlauncher-instances"
name = "PrismLauncher Instances" name = "PrismLauncher Instances"
version = "1.0.0" version = "1.0.1"
plugin_api = 3 plugin_api = 3
author = "radimous" author = "radimous"
license = "MIT" license = "MIT"
dependencies = ["prismlauncher"] dependencies = ["prismlauncher"]
icon = "nut" icon = "nut"
deprecated = false deprecated = false
description = "A launcher provider that adds PrismLauncher instances to the noctalia launcher." description = "A launcher provider that adds PrismLauncher and PrismLauncher fork instances to the noctalia launcher."
tags = ["gaming", "launcher"] tags = ["gaming", "launcher"]
[[launcher_provider]] [[launcher_provider]]
@@ -24,3 +24,10 @@ type = "string"
label_key = "settings.prismlauncher_path.label" label_key = "settings.prismlauncher_path.label"
description_key = "settings.prismlauncher_path.description" description_key = "settings.prismlauncher_path.description"
default = "~/.local/share/PrismLauncher" default = "~/.local/share/PrismLauncher"
[[setting]]
key = "launcher_exec_command"
type = "string"
label_key = "settings.launcher_exec_command.label"
description_key = "settings.launcher_exec_command.description"
default = "prismlauncher"
@@ -5,10 +5,15 @@ local function getPrismPath()
return noctalia.expandPath(prismPath) return noctalia.expandPath(prismPath)
end end
local function getLauncherCommand()
local launcherCommand = noctalia.getConfig("launcher_exec_command")
return noctalia.expandPath(launcherCommand)
end
local function getInstancesDir() local function getInstancesDir()
local prismPath = getPrismPath() local prismPath = getPrismPath()
local configuredInstanceDir = getCfgValue(prismPath .. "/prismlauncher.cfg", "InstanceDir") local configuredInstanceDir = getCfgValue(prismPath .. "/*.cfg", "InstanceDir")
local instances local instances
if configuredInstanceDir == nil then -- default path if configuredInstanceDir == nil then -- default path
@@ -91,8 +96,10 @@ local function shellQuote(s)
end end
function onActivate(id) function onActivate(id)
local launcherCommand = getLauncherCommand()
if id == "" then return end if id == "" then return end
noctalia.runAsync(string.format("prismlauncher --launch %s &>/dev/null", shellQuote(id))) noctalia.runAsync(string.format(launcherCommand .. " --launch %s &>/dev/null", shellQuote(id)))
end end
-- doesn't handle icons that were changed to different default icon, rest is fine -- doesn't handle icons that were changed to different default icon, rest is fine
+12 -8
View File
@@ -1,12 +1,16 @@
{ {
"settings": {
"prismlauncher_path": {
"label": "PrismLauncher path",
"description": "Path to PrismLauncher"
}
},
"no_instances": { "no_instances": {
"title": "No instances found", "subtitle": "Filter: ",
"subtitle": "Filter: " "title": "No instances found"
},
"settings": {
"launcher_exec_command": {
"description": "Start Prismlauncher or fork of choice",
"label": "Prism Executable"
},
"prismlauncher_path": {
"description": "Path to PrismLauncher",
"label": "PrismLauncher path"
}
} }
} }
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"copied-password": "Copied password to clipboard", "copied-password": "Copied password to clipboard",
"failed-list-vaults": "Failed to list vaults. Are you logged into pass-cli?", "failed-list-vaults": "Failed to list vaults. Are you logged into pass-cli?",
"item-not-found": "Item not found", "item-not-found": "Item not found",
"loading-items": "Loading Items…", "loading-items": "Loading Items…",
"loading-vaults": "Loading Vaults…" "loading-vaults": "Loading Vaults…"
} }
+15 -7
View File
@@ -1,9 +1,17 @@
{ {
"title": "ShareDND", "notify": {
"settings.only_active.label": "Only active streams", "no_niri_body": "This looks like a niri session (NIRI_SOCKET is set), but the niri binary is not in PATH, so screen sharing cannot be detected.",
"settings.only_active.description": "Count only screencasts that are actively streaming. When off, any open screencast session (even one paused or showing nothing) keeps Do Not Disturb enabled.", "no_niri_title": "ShareDND: niri not found"
"settings.always_off_after.label": "Always disable DND after sharing", },
"settings.always_off_after.description": "Turn Do Not Disturb off when sharing ends even if it was already enabled before sharing started. When off, DND is only disabled if this plugin enabled it.", "settings": {
"notify.no_niri_title": "ShareDND: niri not found", "always_off_after": {
"notify.no_niri_body": "This looks like a niri session (NIRI_SOCKET is set), but the niri binary is not in PATH, so screen sharing cannot be detected." "description": "Turn Do Not Disturb off when sharing ends even if it was already enabled before sharing started. When off, DND is only disabled if this plugin enabled it.",
"label": "Always disable DND after sharing"
},
"only_active": {
"description": "Count only screencasts that are actively streaming. When off, any open screencast session (even one paused or showing nothing) keeps Do Not Disturb enabled.",
"label": "Only active streams"
}
},
"title": "ShareDND"
} }
+37 -37
View File
@@ -1,12 +1,11 @@
{ {
"title": "Shelly",
"notify": {
"new_updates": {
"title": "New Updates Available",
"description": "There are {count} new updates available for your system."
}
},
"bar": { "bar": {
"click_handler": {
"error": {
"missing_cli": "Shelly CLI is not installed. Please install it to use this feature.",
"missing_gui": "Shelly GUI is not installed. Please install it to use this feature."
}
},
"text": { "text": {
"error": "?", "error": "?",
"updates": { "updates": {
@@ -16,53 +15,54 @@
}, },
"tooltip": { "tooltip": {
"aur_title": "AUR", "aur_title": "AUR",
"flatpak_title": "Flatpak",
"packages_title": "Packages",
"no_updates": "System is up to date",
"error": { "error": {
"decode_json": "Failed to decode output from `shelly check-updates --json`" "decode_json": "Failed to decode output from `shelly check-updates --json`"
} },
}, "flatpak_title": "Flatpak",
"click_handler": { "no_updates": "System is up to date",
"error": { "packages_title": "Packages"
"missing_gui": "Shelly GUI is not installed. Please install it to use this feature.", }
"missing_cli": "Shelly CLI is not installed. Please install it to use this feature." },
} "notify": {
"new_updates": {
"description": "There are {count} new updates available for your system.",
"title": "New Updates Available"
} }
}, },
"settings": { "settings": {
"interval": {
"label": "Refresh Seconds",
"description": "How often the service checks for updates in the background."
},
"notify": {
"label": "Notify",
"description": "Whether to show a notification when new updates are available."
},
"click_action": { "click_action": {
"label": "Click Action",
"description": "What happens when you click on the bar.", "description": "What happens when you click on the bar.",
"label": "Click Action",
"options": { "options": {
"none": "Nothing", "none": "Nothing",
"open_gui": "Open Shelly GUI", "open_gui": "Open Shelly GUI",
"open_updater": "Start Update in Terminal" "open_updater": "Start Update in Terminal"
} }
}, },
"hide_when_up_to_date": {
"label": "Hide When Up-to-Date",
"description": "Whether to hide the bar when there are no updates available."
},
"color": { "color": {
"label": "Color", "description": "The color of the bar text.",
"description": "The color of the bar text." "label": "Color"
}, },
"glyph": { "glyph": {
"label": "Glyph", "description": "The glyph shown before the label.",
"description": "The glyph shown before the label." "label": "Glyph"
}, },
"glyph_color": { "glyph_color": {
"label": "Glyph Color", "description": "The color of the glyph.",
"description": "The color of the glyph." "label": "Glyph Color"
},
"hide_when_up_to_date": {
"description": "Whether to hide the bar when there are no updates available.",
"label": "Hide When Up-to-Date"
},
"interval": {
"description": "How often the service checks for updates in the background.",
"label": "Refresh Seconds"
},
"notify": {
"description": "Whether to show a notification when new updates are available.",
"label": "Notify"
} }
} },
"title": "Shelly"
} }
+24 -18
View File
@@ -1,30 +1,36 @@
{ {
"title": "To Do", "clear_done_confirm": "Delete all done entries?",
"clear_done_no": "Cancel",
"clear_done_yes": "Delete",
"empty": "No tasks yet — add one with +", "empty": "No tasks yet — add one with +",
"placeholder": "New task…", "placeholder": "New task…",
"tooltip": "To Do — {count} to do", "prio_important": "Important",
"save_failed": "Failed to save the task list",
"prio_low": "Low", "prio_low": "Low",
"prio_medium": "Medium", "prio_medium": "Medium",
"prio_important": "Important", "save_failed": "Failed to save the task list",
"sort_priority": "Priority", "settings": {
"glyph": {
"description": "The glyph shown for the To Do widget on the bar.",
"label": "Bar glyph"
},
"todo_folder": {
"description": "Folder holding the task list (todo.json). Defaults to ~/Documents/Todo.",
"label": "To Do folder"
}
},
"sort_manual": "Manual", "sort_manual": "Manual",
"clear_done_confirm": "Delete all done entries?", "sort_priority": "Priority",
"clear_done_yes": "Delete",
"clear_done_no": "Cancel",
"tip_sort": "Switch ordering mode",
"tip_clear_done": "Delete all done tasks",
"tip_add": "Add a task", "tip_add": "Add a task",
"tip_clear_done": "Delete all done tasks",
"tip_close": "Close", "tip_close": "Close",
"tip_commit": "Save (Enter)",
"tip_delete": "Delete task",
"tip_done": "Mark as done",
"tip_edit": "Edit",
"tip_grip": "Reorder — click to pick this row up", "tip_grip": "Reorder — click to pick this row up",
"tip_grip_drop": "Click another grip to drop here, or this one to cancel", "tip_grip_drop": "Click another grip to drop here, or this one to cancel",
"tip_commit": "Save (Enter)", "tip_sort": "Switch ordering mode",
"tip_done": "Mark as done",
"tip_undone": "Mark as to do", "tip_undone": "Mark as to do",
"tip_edit": "Edit", "title": "To Do",
"tip_delete": "Delete task", "tooltip": "To Do — {count} to do"
"settings.todo_folder.label": "To Do folder",
"settings.todo_folder.description": "Folder holding the task list (todo.json). Defaults to ~/Documents/Todo.",
"settings.glyph.label": "Bar glyph",
"settings.glyph.description": "The glyph shown for the To Do widget on the bar."
} }
+7 -7
View File
@@ -1,12 +1,12 @@
{ {
"title": "ASUS UM5606 Fan State",
"settings": { "settings": {
"poll_interval": {
"description": "Poll interval in ms",
"label": "Poll interval"
},
"show_label": { "show_label": {
"label": "Show label" "label": "Show label"
},
"poll_interval": {
"label": "Poll interval",
"description": "Poll interval in ms"
} }
} },
} "title": "ASUS UM5606 Fan State"
}
+14 -14
View File
@@ -1,16 +1,16 @@
{ {
"settings": { "settings": {
"show_centibeats": { "beat_display": {
"label": "Display subbeats", "description": "Toggle internet time notation (.beats)",
"description": "Toggle subbeat display (.xx)" "label": "Display .beats"
}, },
"beat_display": { "show_centibeats": {
"label": "Display .beats", "description": "Toggle subbeat display (.xx)",
"description": "Toggle internet time notation (.beats)" "label": "Display subbeats"
}, },
"time_format_toggle": { "time_format_toggle": {
"label": "Toggle 24HR time", "description": "Display 24 hour time",
"description": "Display 24 hour time" "label": "Toggle 24HR time"
}
} }
} }
}
+38
View File
@@ -0,0 +1,38 @@
# Zed Provider
![Zed Provider thumbnail](thumbnail.webp)
Zed Provider integrates recent Zed workspaces with the Noctalia launcher so you
can reopen a project quickly without leaving the shell.
## Plugin
| Field | Value |
| --- | --- |
| ID | `cleboost/zed-provider` |
| Entry | Launcher provider: `provider` |
| Launcher Prefix | `/zed` |
## Requirements
Install [Zed](https://zed.dev) and ensure `zed` is available on `PATH` as
`zeditor`. The `sqlite3` command must also be available to read Zed's workspace
database. `nohup` command is required to launch Zed.
## Usage
Open the Noctalia launcher and type `/zed` to list recent local Zed workspaces.
Continue typing to filter projects by name, then select one to open it with
`zeditor`.
## Settings
- `db_path` — path to Zed's `db.sqlite` workspace database.
- `max_results` — maximum number of projects shown in the launcher.
## Notes
Projects are read from Zed's workspace database, typically at
`~/.local/share/zed/db/0-stable/db.sqlite`. Remote workspaces are excluded.
The list is cached for the launcher session and refreshed when you clear the
query.
+34
View File
@@ -0,0 +1,34 @@
id = "cleboost/zed-provider"
name = "Zed Provider"
version = "1.0.0"
plugin_api = 3
author = "cleboost"
license = "MIT"
icon = "folder-open"
description = "Open a recent Zed project from the launcher. Type /zed to list your projects."
tags = ["launcher", "development", "productivity"]
dependencies = ["sqlite3", "zed", "nohup"]
[[setting]]
key = "db_path"
type = "file"
label_key = "settings.db_path.label"
description_key = "settings.db_path.description"
default = "~/.local/share/zed/db/0-stable/db.sqlite"
[[setting]]
key = "max_results"
type = "int"
label_key = "settings.max_results.label"
description_key = "settings.max_results.description"
default = 20
min = 1
max = 100
[[launcher_provider]]
id = "provider"
entry = "zed_provider.luau"
prefix = "zed"
glyph = "bolt"
include_in_global_search = false
debounce_ms = 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"database-empty": "Zed database is empty or not found",
"filter-empty": "Filter: \"{filter}\"",
"loading": "Loading…",
"loading-subtitle": "Reading Zed projects",
"no-projects-found": "No projects found",
"settings": {
"db_path": {
"description": "Path to Zed's db.sqlite file.",
"label": "Zed database path"
},
"max_results": {
"description": "Maximum number of projects to display.",
"label": "Maximum results"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"database-empty": "La base Zed est vide ou introuvable",
"filter-empty": "Filtre : « {filter} »",
"loading": "Chargement…",
"loading-subtitle": "Lecture des projets Zed",
"no-projects-found": "Aucun projet trouvé",
"settings": {
"db_path": {
"description": "Chemin vers le fichier db.sqlite de Zed.",
"label": "Chemin de la base Zed"
},
"max_results": {
"description": "Nombre maximum de projets à afficher.",
"label": "Nombre maximum de résultats"
}
}
}
+124
View File
@@ -0,0 +1,124 @@
--!nonstrict
local cachedProjects = nil
local function getDbPath()
local configured = noctalia.getConfig("db_path")
if type(configured) == "string" and configured ~= "" then
return noctalia.expandPath(configured)
end
return noctalia.expandPath("~/.local/share/zed/db/0-stable/db.sqlite")
end
local function getMaxResults()
local n = noctalia.getConfig("max_results")
return (type(n) == "number" and n > 0) and math.floor(n) or 20
end
local function trim(s)
return s:match("^%s*(.-)%s*$")
end
local function projectName(path)
return path:match("([^/]+)$") or path
end
local function shellQuote(s)
return "'" .. s:gsub("'", "'\"'\"'") .. "'"
end
local function loadProjects(onDone)
local db = getDbPath()
local limit = getMaxResults()
local sql = string.format(
"SELECT DISTINCT paths FROM workspaces WHERE paths IS NOT NULL AND paths != '' AND remote_connection_id IS NULL ORDER BY timestamp DESC LIMIT %d;",
limit
)
local cmd = string.format("sqlite3 -separator '\\n' %s %s 2>/dev/null", shellQuote(db), shellQuote(sql))
noctalia.runAsync(cmd, function(result)
local projects = {}
if result.exitCode == 0 and type(result.stdout) == "string" then
for line in result.stdout:gmatch("[^\n]+") do
local path = trim(line)
if path ~= "" then
table.insert(projects, path)
end
end
end
cachedProjects = projects
onDone(projects)
end)
end
local function makeRows(projects, filter)
local home = noctalia.getenv("HOME") or ""
local rows = {}
for _, path in projects do
local name = projectName(path)
local score = filter == "" and 0 or noctalia.fuzzyScore(filter, name)
if score == nil then
score = noctalia.fuzzyScore(filter, path)
end
if filter == "" or score ~= nil then
local display = (home ~= "" and path:sub(1, #home) == home) and "~" .. path:sub(#home + 1) or path
table.insert(rows, {
id = path,
title = name,
subtitle = display,
icon = "zed",
score = filter == "" and nil or score,
})
end
end
return rows
end
local function showResults(query, projects, filter)
local rows = makeRows(projects, filter)
if #rows == 0 then
launcher.setResults(query, {
{
id = "",
title = noctalia.tr("no-projects-found"),
subtitle = filter ~= "" and noctalia.tr("filter-empty", { filter = filter }) or noctalia.tr("database-empty"),
glyph = "folder-x",
},
})
else
launcher.setResults(query, rows)
end
end
function onQuery(query)
local filter = trim(query)
if filter == "" then
cachedProjects = nil
end
if cachedProjects then
showResults(query, cachedProjects, filter)
return
end
launcher.setResults(query, {
{
id = "",
title = noctalia.tr("loading"),
subtitle = noctalia.tr("loading-subtitle"),
glyph = "loader",
},
})
loadProjects(function(projects)
showResults(query, projects, filter)
end)
end
function onActivate(id)
if id == "" then
return
end
noctalia.runAsync(string.format("nohup zeditor %s &>/dev/null &", shellQuote(id)))
end