diff --git a/.tools/i18n-pull.sh b/.tools/i18n-pull.sh new file mode 100755 index 0000000..b3d129d --- /dev/null +++ b/.tools/i18n-pull.sh @@ -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)." diff --git a/.tools/i18n-push.sh b/.tools/i18n-push.sh new file mode 100755 index 0000000..db8ff36 --- /dev/null +++ b/.tools/i18n-push.sh @@ -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 ]" +} + +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" diff --git a/README.md b/README.md index 3080c21..a31f9c3 100644 --- a/README.md +++ b/README.md @@ -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 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 The `tags` in `plugin.toml` are used for catalog search. Tags must be lowercase and selected from this list: diff --git a/catalog.toml b/catalog.toml index 277e90f..4ad8e14 100644 --- a/catalog.toml +++ b/catalog.toml @@ -65,7 +65,7 @@ tags = ["launcher"] [[plugin]] id = "gambled23/mangowm-keymode" name = "Mangowm Keymode" -version = "1.0.1" +version = "1.0.2" author = "gambled23" license = "MIT" icon = "keyboard" @@ -77,7 +77,7 @@ tags = ["bar", "mangowc"] [[plugin]] id = "oldirtty/color_picker" name = "Color Picker" -version = "1.0.2" +version = "1.0.3" author = "oldirtty" license = "MIT" icon = "palette" @@ -99,11 +99,11 @@ tags = ["hyprland", "sway", "desktop", "utility"] [[plugin]] id = "radimous/prismlauncher-instances" name = "PrismLauncher Instances" -version = "1.0.0" +version = "1.0.1" author = "radimous" license = "MIT" 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 plugin_api = 3 tags = ["gaming", "launcher"] @@ -155,7 +155,7 @@ tags = ["hardware"] [[plugin]] id = "rxtsel/portctl" name = "Portctl" -version = "0.2.0" +version = "0.2.1" author = "Cristhian Melo" license = "MIT" 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." plugin_api = 3 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"] diff --git a/color_picker/plugin.toml b/color_picker/plugin.toml index 713b2bc..63e1823 100644 --- a/color_picker/plugin.toml +++ b/color_picker/plugin.toml @@ -3,7 +3,7 @@ id = "oldirtty/color_picker" name = "Color Picker" description = "Pick a color from your screen with hyprpicker." license = "MIT" -version = "1.0.2" +version = "1.0.3" plugin_api = 3 icon = "palette" dependencies = ["hyprpicker"] diff --git a/color_picker/service.luau b/color_picker/service.luau index f0a0d77..f289836 100644 --- a/color_picker/service.luau +++ b/color_picker/service.luau @@ -9,9 +9,9 @@ -- CONSTANTS --============================================== local MAX_HISTORY = 6 -local STATE_DIR = "~/.local/state/noctalia/plugin-cache/community/color_picker" -local HISTORY_FILE = STATE_DIR .. "/history.json" -local SELECTED_FILE = STATE_DIR .. "/selected.json" +local PERSISTENT_DIR = noctalia.pluginDataDir() +local HISTORY_FILE = PERSISTENT_DIR .. "/history.json" +local SELECTED_FILE = PERSISTENT_DIR .. "/selected.json" local tr_color_picker = noctalia.tr("color_picker") --============================================== @@ -101,11 +101,9 @@ end --============================================== -- STATE MODEL -- --- selectedColor / selectedOpacity: ephemeral, what the panel is currently --- showing. Set here on every successful pick; also updated by the panel --- itself for pure-UI interactions (history swatch click, field edit, --- opacity slider) that don't touch hyprpicker or disk. --- +-- selectedColor / selectedOpacity: persisted color, state is saved once +-- per panel session when closed. + -- colorHistory: persisted list. -- Only mutated by pushCurrentColor(), called on immediate-commit picks -- (widget right-click) and on "commit" (panel close). @@ -139,37 +137,37 @@ local function pushCurrentColor(hex: string, opacity: number) end local function loadHistoryFromDisk() - local content = noctalia.readFile(HISTORY_FILE) - if content == nil or content == "" then return end + local raw = noctalia.readFile(HISTORY_FILE) + if raw == nil or raw == "" then return end - local decoded = noctalia.json.decode(content) - if type(decoded) ~= "table" then return end + local decoded = noctalia.json.decode(raw) + if type(decoded) ~= "table" then return end - noctalia.state.set("colorHistory", decoded) + noctalia.state.set("colorHistory", decoded) end local function saveHistoryToDisk() - local history = noctalia.state.get("colorHistory") or {} - local encoded = noctalia.json.encode(history) - if encoded ~= nil then - noctalia.writeFile(HISTORY_FILE, encoded) - end + local history = noctalia.state.get("colorHistory") or {} + local encoded = noctalia.json.encode(history) + if encoded ~= nil then + noctalia.writeFile(HISTORY_FILE, encoded) + end end local function loadSelectedFromDisk() - local content = noctalia.readFile(SELECTED_FILE) - if content == nil or content == "" then return end - - local decoded = noctalia.json.decode(content) + local raw = noctalia.readFile(SELECTED_FILE) + if raw == nil or raw == "" then return end + + local decoded = noctalia.json.decode(raw) if type(decoded) ~= "table" or decoded.hex == nil then return end - + setSelected(decoded.hex, decoded.opacity or 1) end local function saveSelectedToDisk() local hex = noctalia.state.get("selectedColor") if hex == nil then return end - + local opacity = tonumber(noctalia.state.get("selectedOpacity")) or 1 local encoded = noctalia.json.encode({ hex = hex, opacity = opacity }) if encoded ~= nil then @@ -291,7 +289,7 @@ end --============================================== function load() - noctalia.mkdirAll(STATE_DIR) + noctalia.mkdirAll(PERSISTENT_DIR) loadHistoryFromDisk() loadSelectedFromDisk() end diff --git a/color_picker/translations/en.json b/color_picker/translations/en.json index fd0eebe..96f7287 100644 --- a/color_picker/translations/en.json +++ b/color_picker/translations/en.json @@ -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_picked": "Color {color} picked.", - "hyprpicker_not_installed": "hyprpicker is not installed.", + "color_picker": "Color Picker", "could_not_parse": "could not parse hyprpicker output.", "glyph": "Glyph", "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", - - "settings.hyprpicker_format.label": "Default Format", - "settings.hyprpicker_format.description": "Default color format to copy to clipboard (Hex or RGB).", - "settings.swatch_radius.label": "Swatches Corner Roundness", - "settings.swatch_radius.description": "Corner radius of the history swatches and current-color preview.", - "settings.hyprpicker_lowercase.label": "Lowercase Hex", - "settings.hyprpicker_lowercase.description": "Outputs the hexcode in lowercase.", - "settings.hyprpicker_scale.label": "Zoom Scale", - "settings.hyprpicker_scale.description": "Zoom lens magnification, from 1 to 10.", - "settings.hyprpicker_radius.label": "Zoom Radius", - "settings.hyprpicker_radius.description": "Zoom lens circle radius in pixels, from 1 to 1000.", - "settings.hyprpicker_no_zoom.label": "Disable Zoom Lens", - "settings.hyprpicker_no_zoom.description": "Turns off the magnifying zoom lens while picking.", - "settings.hyprpicker_disable_preview.label": "Disable Live Preview", - "settings.hyprpicker_disable_preview.description": "Turns off the live color preview while picking.", - "settings.hyprpicker_cursor.label": "Show cursor", - "settings.hyprpicker_cursor.description": "Includes the cursor in the frozen screen preview." -} \ No newline at end of file + "settings": { + "hyprpicker_cursor": { + "description": "Includes the cursor in the frozen screen preview.", + "label": "Show cursor" + }, + "hyprpicker_disable_preview": { + "description": "Turns off the live color preview while picking.", + "label": "Disable Live Preview" + }, + "hyprpicker_format": { + "description": "Default color format to copy to clipboard (Hex or RGB).", + "label": "Default Format" + }, + "hyprpicker_lowercase": { + "description": "Outputs the hexcode in lowercase.", + "label": "Lowercase Hex" + }, + "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" + } + } +} diff --git a/color_picker/translations/pt-BR.json b/color_picker/translations/pt-BR.json index dc85550..3b34248 100644 --- a/color_picker/translations/pt-BR.json +++ b/color_picker/translations/pt-BR.json @@ -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_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.", "glyph": "Símbolo", "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", - - "settings.hyprpicker_format.label": "Formato Padrão", - "settings.hyprpicker_format.description": "Formato de cor padrão para copiar para a área de transferência (Hex ou RGB).", - "settings.swatch_radius.label": "Arredondamento de Cantos das Amostras", - "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", - "settings.hyprpicker_lowercase.description": "Exibe o código hexadecimal em letras minúsculas.", - "settings.hyprpicker_scale.label": "Escala do Zoom", - "settings.hyprpicker_scale.description": "Ampliação da lupa, de 1 a 10.", - "settings.hyprpicker_radius.label": "Raio do Zoom", - "settings.hyprpicker_radius.description": "Raio do círculo da lupa em pixels, de 1 a 1000.", - "settings.hyprpicker_no_zoom.label": "Desabilitar Lupa", - "settings.hyprpicker_no_zoom.description": "Dpesativa a lupa ao selecionar a cor.", - "settings.hyprpicker_disable_preview.label": "Desabilitar Prévia em Tempo Real", - "settings.hyprpicker_disable_preview.description": "Desativa a prévia da cor em tempo real ao selecionar.", - "settings.hyprpicker_cursor.label": "Mostrar cursor", - "settings.hyprpicker_cursor.description": "Inclui o cursor na prévia congelada da tela." -} \ No newline at end of file + "settings": { + "hyprpicker_cursor": { + "description": "Inclui o cursor na prévia congelada da tela.", + "label": "Mostrar cursor" + }, + "hyprpicker_disable_preview": { + "description": "Desativa a prévia da cor em tempo real ao selecionar.", + "label": "Desabilitar Prévia em Tempo Real" + }, + "hyprpicker_format": { + "description": "Formato de cor padrão para copiar para a área de transferência (Hex ou RGB).", + "label": "Formato Padrão" + }, + "hyprpicker_lowercase": { + "description": "Exibe o código hexadecimal em letras minúsculas.", + "label": "Hexadecimal em Minúsculas" + }, + "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" + } + } +} diff --git a/daily-wallpaper/translations/en.json b/daily-wallpaper/translations/en.json index 8d1260b..b6e215f 100644 --- a/daily-wallpaper/translations/en.json +++ b/daily-wallpaper/translations/en.json @@ -1,9 +1,19 @@ { - "notify.applied": "Wallpaper applied", - "notify.failed": "Daily wallpaper failed", - "settings.locale.description": "Bing market locale such as en-US, de-DE, or fr-FR. Empty uses en-US.", - "settings.locale.label": "Locale", - "settings.source.label": "Source", - "settings.source.options.bing": "Bing", - "settings.source.options.nasa": "NASA" + "notify": { + "applied": "Wallpaper applied", + "failed": "Daily wallpaper failed" + }, + "settings": { + "locale": { + "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" + } + } + } } diff --git a/dropwall/LICENSE b/dropwall/LICENSE new file mode 100644 index 0000000..12baeb1 --- /dev/null +++ b/dropwall/LICENSE @@ -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. diff --git a/dropwall/README.md b/dropwall/README.md new file mode 100644 index 0000000..e441232 --- /dev/null +++ b/dropwall/README.md @@ -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). diff --git a/dropwall/dropwall_copy.py b/dropwall/dropwall_copy.py new file mode 100644 index 0000000..5328b82 --- /dev/null +++ b/dropwall/dropwall_copy.py @@ -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()) diff --git a/dropwall/dropwall_helper.py b/dropwall/dropwall_helper.py new file mode 100644 index 0000000..7b8c99e --- /dev/null +++ b/dropwall/dropwall_helper.py @@ -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 drop surfaces created + ALIVE heartbeat for stream/orphan detection + DROP\t\t\t\t\t\t image dropped; path is percent-encoded + INVALID\t unsupported filename extension + MISSING\t dropped path is not a regular file + ERR\t 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()) diff --git a/dropwall/dropwall_supervisor.py b/dropwall/dropwall_supervisor.py new file mode 100644 index 0000000..0e12f18 --- /dev/null +++ b/dropwall/dropwall_supervisor.py @@ -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()) diff --git a/dropwall/plugin.toml b/dropwall/plugin.toml new file mode 100644 index 0000000..a974882 --- /dev/null +++ b/dropwall/plugin.toml @@ -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" diff --git a/dropwall/service.luau b/dropwall/service.luau new file mode 100644 index 0000000..b2aef6f --- /dev/null +++ b/dropwall/service.luau @@ -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 diff --git a/dropwall/thumbnail.webp b/dropwall/thumbnail.webp new file mode 100644 index 0000000..3b86f59 Binary files /dev/null and b/dropwall/thumbnail.webp differ diff --git a/dropwall/translations/en.json b/dropwall/translations/en.json new file mode 100644 index 0000000..4deb0c0 --- /dev/null +++ b/dropwall/translations/en.json @@ -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" +} diff --git a/file-search/README.md b/file-search/README.md new file mode 100644 index 0000000..2117ea3 --- /dev/null +++ b/file-search/README.md @@ -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 `| 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 ` 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. diff --git a/file-search/file-search.luau b/file-search/file-search.luau new file mode 100644 index 0000000..86d1127 --- /dev/null +++ b/file-search/file-search.luau @@ -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() diff --git a/file-search/launcher.luau b/file-search/launcher.luau new file mode 100644 index 0000000..f73e88a --- /dev/null +++ b/file-search/launcher.luau @@ -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 ` 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 diff --git a/file-search/panel.luau b/file-search/panel.luau new file mode 100644 index 0000000..55a97bf --- /dev/null +++ b/file-search/panel.luau @@ -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=` 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 diff --git a/file-search/plugin.toml b/file-search/plugin.toml new file mode 100644 index 0000000..f887d12 --- /dev/null +++ b/file-search/plugin.toml @@ -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" diff --git a/file-search/thumbnail.webp b/file-search/thumbnail.webp new file mode 100644 index 0000000..5cd5a93 Binary files /dev/null and b/file-search/thumbnail.webp differ diff --git a/file-search/translations/en.json b/file-search/translations/en.json new file mode 100644 index 0000000..82f2aa8 --- /dev/null +++ b/file-search/translations/en.json @@ -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" +} diff --git a/hassio/README.md b/hassio/README.md new file mode 100644 index 0000000..58e8714 --- /dev/null +++ b/hassio/README.md @@ -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. diff --git a/hassio/panel.luau b/hassio/panel.luau new file mode 100644 index 0000000..ff52d45 --- /dev/null +++ b/hassio/panel.luau @@ -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) diff --git a/hassio/plugin.toml b/hassio/plugin.toml new file mode 100644 index 0000000..afef4a2 --- /dev/null +++ b/hassio/plugin.toml @@ -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" diff --git a/hassio/service_sse.luau b/hassio/service_sse.luau new file mode 100644 index 0000000..ead3d84 --- /dev/null +++ b/hassio/service_sse.luau @@ -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 diff --git a/hassio/shortcut.luau b/hassio/shortcut.luau new file mode 100644 index 0000000..664bb48 --- /dev/null +++ b/hassio/shortcut.luau @@ -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() diff --git a/hassio/shortcut_2.luau b/hassio/shortcut_2.luau new file mode 100644 index 0000000..3e27b2f --- /dev/null +++ b/hassio/shortcut_2.luau @@ -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() diff --git a/hassio/shortcut_3.luau b/hassio/shortcut_3.luau new file mode 100644 index 0000000..cf25cfe --- /dev/null +++ b/hassio/shortcut_3.luau @@ -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() diff --git a/hassio/shortcut_4.luau b/hassio/shortcut_4.luau new file mode 100644 index 0000000..369bed1 --- /dev/null +++ b/hassio/shortcut_4.luau @@ -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() diff --git a/hassio/shortcut_panel.luau b/hassio/shortcut_panel.luau new file mode 100644 index 0000000..3b0e832 --- /dev/null +++ b/hassio/shortcut_panel.luau @@ -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 diff --git a/hassio/thumbnail.webp b/hassio/thumbnail.webp new file mode 100644 index 0000000..af01f61 Binary files /dev/null and b/hassio/thumbnail.webp differ diff --git a/hassio/translations/en.json b/hassio/translations/en.json new file mode 100644 index 0000000..e7b6e7d --- /dev/null +++ b/hassio/translations/en.json @@ -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)" + } +} diff --git a/hassio/translations/pt-BR.json b/hassio/translations/pt-BR.json new file mode 100644 index 0000000..d5ef560 --- /dev/null +++ b/hassio/translations/pt-BR.json @@ -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)" + } +} diff --git a/hassio/widget.luau b/hassio/widget.luau new file mode 100644 index 0000000..07e6d60 --- /dev/null +++ b/hassio/widget.luau @@ -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 diff --git a/ideapad-conservation-mode/translations/en.json b/ideapad-conservation-mode/translations/en.json index 1dd0538..b881c93 100644 --- a/ideapad-conservation-mode/translations/en.json +++ b/ideapad-conservation-mode/translations/en.json @@ -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": { "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.", "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." - } + }, + "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" } diff --git a/iio_lock/translations/en.json b/iio_lock/translations/en.json index 6d4f88c..b3762e1 100644 --- a/iio_lock/translations/en.json +++ b/iio_lock/translations/en.json @@ -1,29 +1,45 @@ { - "settings.iio.label": "IIO Tool", - "settings.iio.description": "Executable used to control orientation", - "settings.iio.options.sway":"iio-sway", - "settings.iio.options.hyprland":"iio-hyprland", - - "settings.transform.label": "Transform Order", - "settings.transform.description":"Rotation values for Up, Left, Down, Right.", - - "settings.wm.label": "Window Manager", - "settings.wm.description": "Choose the window manager backend.", - "settings.wm.options.sway":"Sway", - "settings.wm.options.hyprland":"Hyprland", - - - "settings.glyph.locked.label": "Locked Glyph", - "settings.glyph.locked.description": "Icon displayed when the device is locked.", - - "settings.glyph.unlocked.label": "Unlocked Glyph", - "settings.glyph.unlocked.description": "Icon displayed when the device is unlocked.", - - - "panel.title": "iio-lock", - "panel.lock": "Toggle Lock", - - "widget.locked": "Locked", - "widget.unlocked": "Unlocked", - "toggle.description": "Toggle orientation lock" + "panel": { + "lock": "Toggle Lock", + "title": "iio-lock" + }, + "settings": { + "glyph": { + "locked": { + "description": "Icon displayed when the device is locked.", + "label": "Locked Glyph" + }, + "unlocked": { + "description": "Icon displayed when the device is unlocked.", + "label": "Unlocked Glyph" + } + }, + "iio": { + "description": "Executable used to control orientation", + "label": "IIO Tool", + "options": { + "hyprland": "iio-hyprland", + "sway": "iio-sway" + } + }, + "transform": { + "description": "Rotation values for Up, Left, Down, Right.", + "label": "Transform Order" + }, + "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" + } } diff --git a/lyrics/README.md b/lyrics/README.md new file mode 100644 index 0000000..faf4729 --- /dev/null +++ b/lyrics/README.md @@ -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 '' +``` + +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. diff --git a/lyrics/krc_decode.py b/lyrics/krc_decode.py new file mode 100644 index 0000000..fc2ab34 --- /dev/null +++ b/lyrics/krc_decode.py @@ -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` 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() diff --git a/lyrics/lrclib_lyric.py b/lyrics/lrclib_lyric.py new file mode 100644 index 0000000..9c953fb --- /dev/null +++ b/lyrics/lrclib_lyric.py @@ -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() diff --git a/lyrics/lyrics.luau b/lyrics/lyrics.luau new file mode 100644 index 0000000..6458607 --- /dev/null +++ b/lyrics/lyrics.luau @@ -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) diff --git a/lyrics/lyrics_service.luau b/lyrics/lyrics_service.luau new file mode 100644 index 0000000..4953c47 --- /dev/null +++ b/lyrics/lyrics_service.luau @@ -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 diff --git a/lyrics/plugin.toml b/lyrics/plugin.toml new file mode 100644 index 0000000..ec8f2bc --- /dev/null +++ b/lyrics/plugin.toml @@ -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" diff --git a/lyrics/screenshots/settings.webp b/lyrics/screenshots/settings.webp new file mode 100644 index 0000000..fd049ad Binary files /dev/null and b/lyrics/screenshots/settings.webp differ diff --git a/lyrics/screenshots/widget.webp b/lyrics/screenshots/widget.webp new file mode 100644 index 0000000..f85b784 Binary files /dev/null and b/lyrics/screenshots/widget.webp differ diff --git a/lyrics/scripts/setup-deps.sh b/lyrics/scripts/setup-deps.sh new file mode 100755 index 0000000..08b5bd0 --- /dev/null +++ b/lyrics/scripts/setup-deps.sh @@ -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." diff --git a/lyrics/thumbnail.webp b/lyrics/thumbnail.webp new file mode 100644 index 0000000..cf876db Binary files /dev/null and b/lyrics/thumbnail.webp differ diff --git a/lyrics/translations/en.json b/lyrics/translations/en.json new file mode 100644 index 0000000..e62e25b --- /dev/null +++ b/lyrics/translations/en.json @@ -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" +} diff --git a/mangowm-keymode/README.md b/mangowm-keymode/README.md index 43c08f4..2373ee5 100644 --- a/mangowm-keymode/README.md +++ b/mangowm-keymode/README.md @@ -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. | | `notify_change` | `bool` | `true` | Sends a notification when the keymode changes. | +| `hide_on_default` | `bool` | `false` | Hides the widget when default keymode is active. | diff --git a/mangowm-keymode/keymode.luau b/mangowm-keymode/keymode.luau index 8924061..dbac79c 100644 --- a/mangowm-keymode/keymode.luau +++ b/mangowm-keymode/keymode.luau @@ -1,5 +1,6 @@ local showText = noctalia.getConfig("show_text") local notifyChange = noctalia.getConfig("notify_change") +local hideOnDefault = noctalia.getConfig("hide_on_default") local currentKeymode = "default" @@ -17,11 +18,16 @@ end) function update() noctalia.setUpdateInterval(1000) - barWidget.setGlyph("keyboard") - if showText then - barWidget.setText(currentKeymode) + if hideOnDefault and currentKeymode == "default" then + barWidget.setVisible(false) else - barWidget.setText("") + barWidget.setVisible(true) + barWidget.setGlyph("keyboard") + if showText then + barWidget.setText(currentKeymode) + else + barWidget.setText("") + end end end diff --git a/mangowm-keymode/plugin.toml b/mangowm-keymode/plugin.toml index 499db6e..9ce3aa1 100644 --- a/mangowm-keymode/plugin.toml +++ b/mangowm-keymode/plugin.toml @@ -1,6 +1,6 @@ id = "gambled23/mangowm-keymode" name = "Mangowm Keymode" -version = "1.0.1" +version = "1.0.2" plugin_api = 3 author = "gambled23" license = "MIT" @@ -14,6 +14,12 @@ tags = ["bar", "mangowc"] id = "mangowm-keymode" entry = "keymode.luau" + [[widget.setting]] + key = "hide_on_default" + type = "bool" + label_key = "settings.hide_on_default.label" + default = false + [[widget.setting]] key = "show_text" type = "bool" diff --git a/mangowm-keymode/thumbnail.webp b/mangowm-keymode/thumbnail.webp index 890db85..ed0b978 100644 Binary files a/mangowm-keymode/thumbnail.webp and b/mangowm-keymode/thumbnail.webp differ diff --git a/mangowm-keymode/translations/en.json b/mangowm-keymode/translations/en.json index 96c7ad5..7af21f0 100644 --- a/mangowm-keymode/translations/en.json +++ b/mangowm-keymode/translations/en.json @@ -1,4 +1,13 @@ { - "settings.show_text.label": "Show text on bar", - "settings.notify_change.label": "Notify change" + "settings": { + "hide_on_default": { + "label": "Hide on default keymode" + }, + "notify_change": { + "label": "Notify change" + }, + "show_text": { + "label": "Show text on bar" + } + } } diff --git a/mini-docker/translations/en.json b/mini-docker/translations/en.json index 16ab6a2..6173b39 100644 --- a/mini-docker/translations/en.json +++ b/mini-docker/translations/en.json @@ -1,116 +1,116 @@ { - "title": "Mini Docker", - "widget": { - "running": "Running containers: {count}", - "unavailable": "Docker is not available", - "refresh_requested": "Docker refresh requested" - }, - "settings": { - "refresh_interval": { - "label": "Refresh interval", - "description": "How often Mini Docker refreshes Docker state, in seconds." - }, - "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." - } + "actions": { + "cancel": "Cancel", + "close": "Close", + "refresh": "Refresh", + "remove": "Remove", + "restart": "Restart", + "run": "Run", + "select": "Select", + "start": "Start", + "stop": "Stop" }, "colors": { "default": "Default", + "error": "Error", + "muted": "Muted", "primary": "Primary", "secondary": "Secondary", - "tertiary": "Tertiary", "success": "Success", - "error": "Error", - "muted": "Muted" + "tertiary": "Tertiary" + }, + "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": { "containers": "Containers", "images": "Images", - "volumes": "Volumes", - "networks": "Networks" + "networks": "Networks", + "volumes": "Volumes" }, - "actions": { - "refresh": "Refresh", - "select": "Select", - "start": "Start", - "stop": "Stop", - "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." + "title": "Mini Docker", + "widget": { + "refresh_requested": "Docker refresh requested", + "running": "Running containers: {count}", + "unavailable": "Docker is not available" } } diff --git a/nix-monitor/translations/en.json b/nix-monitor/translations/en.json index 23e9d7e..74140ec 100644 --- a/nix-monitor/translations/en.json +++ b/nix-monitor/translations/en.json @@ -1,84 +1,156 @@ { - "bar.checking": "Checking...", - "bar.update_available": "Update available!", - "bar.up_to_date": "Up to date", - "bar.unknown": "Unknown", - "notification.checking_update": "Checking NixOS update...", - "notification.update_is_available": "NixOS update is available!", - "notification.update_check_completed": "NixOS update check completed!", - "notification.update_command_is_empty": "Update command is empty!", - "notification.optimize_command_is_empty": "Optimize command is empty!", - "notification.clean_command_is_empty": "Clean command is empty!", - "panel.title": "Nix Monitor", - "panel.cancel": "Cancel", - "panel.update": "Update", - "panel.check": "Check", - "panel.optimize": "Optimize", - "panel.clean": "Clean", - "panel.local": "Local", - "panel.remote": "Remote", - "panel.last_checked": "Last checked", - "panel.current": "Current", - "panel.store_size": "Store size", - "panel.closure_size": "Closure size", - "terminal.press_enter_to_exit": "Press enter to exit...", - "setting.widget.show_text.label": "Show text", - "setting.widget.colorize_text.label": "Colorize text", - "setting.widget.show_glyph.label": "Show glyph", - "setting.widget.colorize_glyph.label": "Colorize glyph", - "setting.widget.up_to_date_glyph.label": "Up to date glyph", - "setting.widget.up_to_date_color.label": "Up to date color", - "setting.widget.checking_glyph.label": "Checking glyph", - "setting.widget.checking_color.label": "Checking color", - "setting.widget.update_available_glyph.label": "Update available glyph", - "setting.widget.update_available_color.label": "Update available color", - "setting.widget.unknown_glyph.label": "Unknown status glyph", - "setting.widget.unknown_color.label": "Unknown status color", - "setting.widget.panel_card_background_color.label": "Panel card background color", - "setting.widget.panel_card_background_opacity.label": "Panel card background opacity", - "setting.update_check_interval.label": "Update check interval", - "setting.update_check_interval.description": "How often to check for updates (in minutes)", - "setting.update_check_duration_threshold.label": "Update check duration threshold", - "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", - "setting.system_stats_check_interval.description": "How often to check for system stats (in minutes)", - "setting.system_stats_check_duration_threshold.label": "System stats check duration threshold", - "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", - "setting.show_update_check_notification.description": "Whether to show a notification whenever update check is in progress and completed", - "setting.show_update_available_notification.label": "Show update available notification", - "setting.show_update_available_notification.description": "Whether to show a notification whenever an update is available", - "setting.branch.label": "Branch", - "setting.branch.description": "Which nixpkgs branch to use", - "setting.update_command.label": "Update command", - "setting.update_command.description": "Command to run when clicking Update button", - "setting.optimize_command.label": "Optimize command", - "setting.optimize_command.description": "Command to run when clicking Optimize button", - "setting.clean_command.label": "Clean command", - "setting.clean_command.description": "Command to run when clicking Clean button", - "setting.hide_update_button.label": "Hide update button", - "setting.hide_update_button.description": "Whether to hide the update button on the panel", - "setting.hide_optimize_button.label": "Hide optimize button", - "setting.hide_optimize_button.description": "Whether to hide the optimize button on the panel", - "setting.hide_clean_button.label": "Hide clean button", - "setting.hide_clean_button.description": "Whether to hide the clean button on the panel", - "setting.close_on_enter.label": "Close on press Enter", - "setting.close_on_enter.description": "Add 'Press enter to exit' on the terminal window", - "setting.option.branch.master": "Master", - "setting.option.branch.nixos_unstable": "NixOS Unstable", - "setting.option.branch.nixos_unstable_small": "NixOS Unstable Small", - "setting.option.branch.nixos_26_05": "NixOS 26.05", - "setting.option.branch.nixos_25_11": "NixOS 25.11", - "setting.option.branch.nixos_25_05": "NixOS 25.05", - "setting.option.branch.nixos_24_11": "NixOS 24.11", - "setting.option.branch.nixos_24_05": "NixOS 24.05", - "setting.option.branch.nixos_23_11": "NixOS 23.11", - "setting.option.branch.nixos_23_05": "NixOS 23.05", - "setting.option.branch.nixos_26_05_small": "NixOS 26.05 Small", - "setting.option.branch.nixos_25_11_small": "NixOS 25.11 Small", - "setting.option.branch.nixos_25_05_small": "NixOS 25.05 Small", - "setting.option.branch.nixos_24_11_small": "NixOS 24.11 Small", - "setting.option.branch.nixos_24_05_small": "NixOS 24.05 Small", - "setting.option.branch.nixos_23_11_small": "NixOS 23.11 Small", - "setting.option.branch.nixos_23_05_small": "NixOS 23.05 Small" + "bar": { + "checking": "Checking...", + "unknown": "Unknown", + "up_to_date": "Up to date", + "update_available": "Update available!" + }, + "notification": { + "checking_update": "Checking NixOS update...", + "clean_command_is_empty": "Clean command is empty!", + "optimize_command_is_empty": "Optimize command is empty!", + "update_check_completed": "NixOS update check completed!", + "update_command_is_empty": "Update command is empty!", + "update_is_available": "NixOS update is available!" + }, + "panel": { + "cancel": "Cancel", + "check": "Check", + "optimize": "Optimize", + "clean": "Clean", + "closure_size": "Closure size", + "current": "Current", + "last_checked": "Last checked", + "local": "Local", + "remote": "Remote", + "store_size": "Store size", + "title": "Nix Monitor", + "update": "Update" + }, + "setting": { + "branch": { + "description": "Which nixpkgs branch to use", + "label": "Branch" + }, + "update_command": { + "description": "Command to run when clicking Update button", + "label": "Update command" + }, + "optimize_command": { + "label": "Optimize command", + "description": "Command to run when clicking Optimize button" + }, + "clean_command": { + "description": "Command to run when clicking Clean button", + "label": "Clean command" + }, + "close_on_enter": { + "description": "Add 'Press enter to exit' on the terminal window", + "label": "Close on press Enter" + }, + "hide_update_button": { + "description": "Whether to hide the update button on the panel", + "label": "Hide update button" + }, + "hide_optimize_button": { + "label": "Hide optimize button", + "description": "Whether to hide the optimize button on the panel" + }, + "hide_clean_button": { + "description": "Whether to hide the clean button on the panel", + "label": "Hide clean button" + }, + "option": { + "branch": { + "master": "Master", + "nixos_23_05": "NixOS 23.05", + "nixos_23_05_small": "NixOS 23.05 Small", + "nixos_23_11": "NixOS 23.11", + "nixos_23_11_small": "NixOS 23.11 Small", + "nixos_24_05": "NixOS 24.05", + "nixos_24_05_small": "NixOS 24.05 Small", + "nixos_24_11": "NixOS 24.11", + "nixos_24_11_small": "NixOS 24.11 Small", + "nixos_25_05": "NixOS 25.05", + "nixos_25_05_small": "NixOS 25.05 Small", + "nixos_25_11": "NixOS 25.11", + "nixos_25_11_small": "NixOS 25.11 Small", + "nixos_26_05": "NixOS 26.05", + "nixos_26_05_small": "NixOS 26.05 Small", + "nixos_unstable": "NixOS Unstable", + "nixos_unstable_small": "NixOS Unstable 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..." + } } diff --git a/noctwhspr/README.md b/noctwhspr/README.md new file mode 100644 index 0000000..fef78f3 --- /dev/null +++ b/noctwhspr/README.md @@ -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 `/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. diff --git a/noctwhspr/plugin.toml b/noctwhspr/plugin.toml new file mode 100644 index 0000000..ebfe1f6 --- /dev/null +++ b/noctwhspr/plugin.toml @@ -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" diff --git a/noctwhspr/thumbnail.webp b/noctwhspr/thumbnail.webp new file mode 100644 index 0000000..1f109e0 Binary files /dev/null and b/noctwhspr/thumbnail.webp differ diff --git a/noctwhspr/translations/en.json b/noctwhspr/translations/en.json new file mode 100644 index 0000000..591cefc --- /dev/null +++ b/noctwhspr/translations/en.json @@ -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" +} diff --git a/noctwhspr/widget.luau b/noctwhspr/widget.luau new file mode 100644 index 0000000..f0c326e --- /dev/null +++ b/noctwhspr/widget.luau @@ -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…") diff --git a/portctl/panel.luau b/portctl/panel.luau index 3aad2ab..2087f77 100644 --- a/portctl/panel.luau +++ b/portctl/panel.luau @@ -277,7 +277,7 @@ local function catSection(catName, ports) fill = "surface_container/0.4", }, { 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" }), })) diff --git a/portctl/plugin.toml b/portctl/plugin.toml index abaa635..a89e4bf 100644 --- a/portctl/plugin.toml +++ b/portctl/plugin.toml @@ -2,7 +2,7 @@ id = "rxtsel/portctl" name = "Portctl" -version = "0.2.0" +version = "0.2.1" plugin_api = 3 author = "Cristhian Melo" license = "MIT" diff --git a/portctl/translations/en.json b/portctl/translations/en.json index 9c3084f..d7644f8 100644 --- a/portctl/translations/en.json +++ b/portctl/translations/en.json @@ -1,54 +1,54 @@ { - "title": "Portctl", + "categories": { + "cloud": "Cloud", + "containers": "Containers", + "databases": "Databases", + "development": "Development", + "other": "Other", + "servers": "Servers" + }, "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", "kill": "Kill", - "pid_copied": "Copied PID {pid}" - }, - "categories": { - "Development": "Development", - "Databases": "Databases", - "Containers": "Containers", - "Servers": "Servers", - "Cloud": "Cloud", - "Other": "Other" + "kill_confirm": "Kill {name}?", + "killing": "Killing {name}…", + "no_ports": "No listening ports", + "no_results": "No results for \"{query}\"", + "pid_copied": "Copied PID {pid}", + "search_placeholder": "Search port, process, PID…", + "tcp": "TCP", + "udp": "UDP" }, "service": { - "terminated": "Process {pid} terminated", "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": { - "refresh_interval": { - "label": "Refresh interval", - "description": "Seconds between port scans" - }, - "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)" + "glyph": { + "description": "Icon shown in the bar.", + "label": "Glyph" }, "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": { - "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": { - "label": "Glyph", - "description": "Icon shown in the bar." + "ignore_list": { + "description": "Comma-separated process name substrings to hide (e.g. discord,chrome,steam)", + "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" } diff --git a/prismlauncher-instances/plugin.toml b/prismlauncher-instances/plugin.toml index 75fa0b8..8986efd 100644 --- a/prismlauncher-instances/plugin.toml +++ b/prismlauncher-instances/plugin.toml @@ -1,13 +1,13 @@ id = "radimous/prismlauncher-instances" name = "PrismLauncher Instances" -version = "1.0.0" +version = "1.0.1" plugin_api = 3 author = "radimous" license = "MIT" dependencies = ["prismlauncher"] icon = "nut" 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"] [[launcher_provider]] @@ -24,3 +24,10 @@ type = "string" label_key = "settings.prismlauncher_path.label" description_key = "settings.prismlauncher_path.description" 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" \ No newline at end of file diff --git a/prismlauncher-instances/prismlauncher-instances.luau b/prismlauncher-instances/prismlauncher-instances.luau index 59331c0..865c885 100644 --- a/prismlauncher-instances/prismlauncher-instances.luau +++ b/prismlauncher-instances/prismlauncher-instances.luau @@ -5,10 +5,15 @@ local function getPrismPath() return noctalia.expandPath(prismPath) end +local function getLauncherCommand() + local launcherCommand = noctalia.getConfig("launcher_exec_command") + return noctalia.expandPath(launcherCommand) +end + local function getInstancesDir() local prismPath = getPrismPath() - local configuredInstanceDir = getCfgValue(prismPath .. "/prismlauncher.cfg", "InstanceDir") + local configuredInstanceDir = getCfgValue(prismPath .. "/*.cfg", "InstanceDir") local instances if configuredInstanceDir == nil then -- default path @@ -91,8 +96,10 @@ local function shellQuote(s) end function onActivate(id) + local launcherCommand = getLauncherCommand() + 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 -- doesn't handle icons that were changed to different default icon, rest is fine diff --git a/prismlauncher-instances/translations/en.json b/prismlauncher-instances/translations/en.json index b8e4959..cda2d65 100644 --- a/prismlauncher-instances/translations/en.json +++ b/prismlauncher-instances/translations/en.json @@ -1,12 +1,16 @@ { - "settings": { - "prismlauncher_path": { - "label": "PrismLauncher path", - "description": "Path to PrismLauncher" - } - }, "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" + } } } diff --git a/proton-pass/translations/en.json b/proton-pass/translations/en.json index ed49843..2d47d44 100644 --- a/proton-pass/translations/en.json +++ b/proton-pass/translations/en.json @@ -1,7 +1,7 @@ { - "copied-password": "Copied password to clipboard", - "failed-list-vaults": "Failed to list vaults. Are you logged into pass-cli?", - "item-not-found": "Item not found", - "loading-items": "Loading Items…", - "loading-vaults": "Loading Vaults…" + "copied-password": "Copied password to clipboard", + "failed-list-vaults": "Failed to list vaults. Are you logged into pass-cli?", + "item-not-found": "Item not found", + "loading-items": "Loading Items…", + "loading-vaults": "Loading Vaults…" } diff --git a/sharednd/translations/en.json b/sharednd/translations/en.json index 02e33af..cfcd0dd 100644 --- a/sharednd/translations/en.json +++ b/sharednd/translations/en.json @@ -1,9 +1,17 @@ { - "title": "ShareDND", - "settings.only_active.label": "Only active streams", - "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.", - "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.", - "notify.no_niri_title": "ShareDND: niri not found", - "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." + "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.", + "no_niri_title": "ShareDND: niri not found" + }, + "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.", + "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" } diff --git a/shelly/translations/en.json b/shelly/translations/en.json index 25430a7..ac9f5a6 100644 --- a/shelly/translations/en.json +++ b/shelly/translations/en.json @@ -1,12 +1,11 @@ { - "title": "Shelly", - "notify": { - "new_updates": { - "title": "New Updates Available", - "description": "There are {count} new updates available for your system." - } - }, "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": { "error": "?", "updates": { @@ -16,53 +15,54 @@ }, "tooltip": { "aur_title": "AUR", - "flatpak_title": "Flatpak", - "packages_title": "Packages", - "no_updates": "System is up to date", "error": { "decode_json": "Failed to decode output from `shelly check-updates --json`" - } - }, - "click_handler": { - "error": { - "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." - } + }, + "flatpak_title": "Flatpak", + "no_updates": "System is up to date", + "packages_title": "Packages" + } + }, + "notify": { + "new_updates": { + "description": "There are {count} new updates available for your system.", + "title": "New Updates Available" } }, "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": { - "label": "Click Action", "description": "What happens when you click on the bar.", + "label": "Click Action", "options": { "none": "Nothing", "open_gui": "Open Shelly GUI", "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": { - "label": "Color", - "description": "The color of the bar text." + "description": "The color of the bar text.", + "label": "Color" }, "glyph": { - "label": "Glyph", - "description": "The glyph shown before the label." + "description": "The glyph shown before the label.", + "label": "Glyph" }, "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" } diff --git a/todo/translations/en.json b/todo/translations/en.json index 2af0fdd..8e15a46 100644 --- a/todo/translations/en.json +++ b/todo/translations/en.json @@ -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 +", "placeholder": "New task…", - "tooltip": "To Do — {count} to do", - "save_failed": "Failed to save the task list", + "prio_important": "Important", "prio_low": "Low", "prio_medium": "Medium", - "prio_important": "Important", - "sort_priority": "Priority", + "save_failed": "Failed to save the task list", + "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", - "clear_done_confirm": "Delete all done entries?", - "clear_done_yes": "Delete", - "clear_done_no": "Cancel", - "tip_sort": "Switch ordering mode", - "tip_clear_done": "Delete all done tasks", + "sort_priority": "Priority", "tip_add": "Add a task", + "tip_clear_done": "Delete all done tasks", "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_drop": "Click another grip to drop here, or this one to cancel", - "tip_commit": "Save (Enter)", - "tip_done": "Mark as done", + "tip_sort": "Switch ordering mode", "tip_undone": "Mark as to do", - "tip_edit": "Edit", - "tip_delete": "Delete task", - "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." + "title": "To Do", + "tooltip": "To Do — {count} to do" } diff --git a/um5606_fan_state/translations/en.json b/um5606_fan_state/translations/en.json index 815535d..c5f1f9e 100644 --- a/um5606_fan_state/translations/en.json +++ b/um5606_fan_state/translations/en.json @@ -1,12 +1,12 @@ { - "title": "ASUS UM5606 Fan State", "settings": { + "poll_interval": { + "description": "Poll interval in ms", + "label": "Poll interval" + }, "show_label": { "label": "Show label" - }, - "poll_interval": { - "label": "Poll interval", - "description": "Poll interval in ms" } - } -} \ No newline at end of file + }, + "title": "ASUS UM5606 Fan State" +} diff --git a/upbeat/translations/en.json b/upbeat/translations/en.json index 357c74a..3a514bb 100644 --- a/upbeat/translations/en.json +++ b/upbeat/translations/en.json @@ -1,16 +1,16 @@ { - "settings": { - "show_centibeats": { - "label": "Display subbeats", - "description": "Toggle subbeat display (.xx)" - }, - "beat_display": { - "label": "Display .beats", - "description": "Toggle internet time notation (.beats)" - }, - "time_format_toggle": { - "label": "Toggle 24HR time", - "description": "Display 24 hour time" - } + "settings": { + "beat_display": { + "description": "Toggle internet time notation (.beats)", + "label": "Display .beats" + }, + "show_centibeats": { + "description": "Toggle subbeat display (.xx)", + "label": "Display subbeats" + }, + "time_format_toggle": { + "description": "Display 24 hour time", + "label": "Toggle 24HR time" } - } \ No newline at end of file + } +} diff --git a/zed-provider/README.md b/zed-provider/README.md new file mode 100644 index 0000000..fd10db2 --- /dev/null +++ b/zed-provider/README.md @@ -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. diff --git a/zed-provider/plugin.toml b/zed-provider/plugin.toml new file mode 100644 index 0000000..a9743f0 --- /dev/null +++ b/zed-provider/plugin.toml @@ -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 diff --git a/zed-provider/thumbnail.webp b/zed-provider/thumbnail.webp new file mode 100644 index 0000000..5ccde31 Binary files /dev/null and b/zed-provider/thumbnail.webp differ diff --git a/zed-provider/translations/en.json b/zed-provider/translations/en.json new file mode 100644 index 0000000..3ba3b23 --- /dev/null +++ b/zed-provider/translations/en.json @@ -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" + } + } +} diff --git a/zed-provider/translations/fr.json b/zed-provider/translations/fr.json new file mode 100644 index 0000000..8c9a1d7 --- /dev/null +++ b/zed-provider/translations/fr.json @@ -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" + } + } +} diff --git a/zed-provider/zed_provider.luau b/zed-provider/zed_provider.luau new file mode 100644 index 0000000..1858690 --- /dev/null +++ b/zed-provider/zed_provider.luau @@ -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