diff --git a/system-updater/README.md b/system-updater/README.md new file mode 100644 index 00000000..c782b207 --- /dev/null +++ b/system-updater/README.md @@ -0,0 +1,174 @@ +# System Updates Manager + +Manage system updates inside the noctalia panel. Currently supported PackageKit, Flatpak and Cargo. + +# Features + +* Check updates with [PackageKit](https://www.freedesktop.org/software/PackageKit), [Flatpak](https://flatpak.org/) and Cargo. +* Extensible with user created adapters +* Support for offline updates with PackageKit +* Single package update is supported +* Show brief package information: Name, description, installed version, updated version +* Show application icon if found +* Show number of pending packages in the bar widget + +![System Updates Manager panel](screenshots/panel.png) + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `tordex/system-updater` | +| Entries | Bar widget: `status`; panel: `panel`; service: `service` | + +## Requirements + +All adapters require ```python``` to run check update scripts. You have to install ```pycairo``` ```PyGObject``` modules with ```pip```: + +```sh +pip install pycairo PyGObject +``` + +Other requirements are depend of update adapter. Plugin disables update adapter if any of its own dependences are not installed. + +### PackageKit + +PackageKit update adapter requires ```python``` and ```pkgcli``` to be installed into ```$PATH```. + +### Flatpak + +Flatpak update adapter requires ```python``` and ```flatpak``` to be installed into ```$PATH```. + +### Cargo + +Cargo update adapter requires ```cargo```, ```python```, ```cargo-install-update```, `awk` and `bash` to be installed into ```$PATH```. + +## Usage + +Add the system-updater widget from Noctalia's widget picker, then click it to open the panel. You can also open the panel directly or bind it in your compositor: + +```sh +noctalia msg panel-toggle tordex/system-updater:panel +``` + +| Action | Effect | +|-----------------------------------|-------------------------------------------------------------| +| Left click (bar glyph) | Open/close the panel | +| **Check Updates** (panel) | Check for new updates | +| **Update** (panel) | Start applying updates | +| ↻ refresh (panel header) | Same as **Check Updates** | +| ⚙ settings (panel header) | Open this plugin's page in *Settings → Plugins* | + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `enable_packagekit` | `bool` | `true` | Enable the PackageKit adapter for managing system updates. | +| `enable_flatpak` | `bool` | `true` | Enable the Flatpak adapter for managing system updates. | +| `enable_cargo` | `bool` | `true` | Enable the Cargo adapter for managing system updates. | +| `auto_check_minutes` | `int` | `60` | Check for updates automatically every N minutes. if <= 10 never checks on its own — nothing runs until you ask for it. | +| `adapters_folder` | `folder` | | The folder where adapters definitions are stored. One adapter per subfolder. Each adapter folder must contain an adapter.json with the adapter definition. | +| `notify_on_updates` | `bool` | `true` | Send a desktop notification when a check finds packages to upgrade or after applying updates. | +| `notify_on_updates` | `bool` | `true` | Send a desktop notification when a check finds packages to upgrade or after applying updates. | +| `glyph` | `glyph` | `package` | The glyph shown for the system updater widget on the bar. | +| `show_count` | `bool` | `true` | Show the number of pending updates next to the bar glyph. | + +## IPC + +IPC command to start updates checking: +```sh +noctalia msg plugin tordex/system-updater:service all check +``` + +IPC command to start update applying updates: +```sh +noctalia msg plugin tordex/system-updater:service all update +``` + +## Notes + +You can define the folder with your own adapters the setting `adapters_folder`. Plugin will read custom adapters and include them into ckecking/updating process. + +### How to write update adapter + +1. Adapter must be located inside single folder +2. Adapter folder must have `adapter.json` file with adapter definitions +3. You have to write a script to check updates that provides output in the formap plugin understand + +### adapter.json file + +Adpater definitions in format: +```json +{ + "name": "PackageKit", + "enabled": true, + "dependencies": ["python", "pkgcli"], + "check_command": "python {adapter_dir}/check.py", + "update_package_command": "pkgcli -y -q update {package_name}", + "update_all_command": "pkgcli -y -q offline-update prepare", + "actions": { + "reboot": { + "command": "noctalia msg session reboot", + "check_after": false + }, + "cancel": { + "command": "pkgcli -y -q offline-update cancel", + "check_after": true + } + } +} +``` +| Field | Type | Description | +| --- | --- | --- | +| `name` | `string` | The update adapter name. Please don't use spaces. | +| `enables` | `bool` | Disable or enable adapter. | +| `check_command` | `string` | The shell command to check updates. Output should be in the specified format. | +| `update_package_command` | `string` | The shell command to update the single package. Use {package_name} placeholder as the package parameter | +| `update_all_command` | `string` | The shell command to update all pending packages. | +| `actions` | `string` | The supported by adapter actions dictinary. | + +For commands use `{adapter_dir}` placeholder as the full path to the adapter folder. + +Actions are the spesial buttons shown after update checking. `adapter.json` has the disctionary of available actions. Every action has two fileds: + +| Field | Type | Description | +| --- | --- | --- | +| `command` | `string` | The shell script to run. | +| `check_after` | `bool` | When `true` plugin will run check update for this adapter after running the action command. | + +### `check_command` output format + +```json +{ + "info": "", + "actions": [ + { + "name": "actions name/label", + "command": "action_id", + } + ], + "updates": [ + { + "id": "package_id", + "name": "package_name", + "icon": "path to the icon", + "description": "package description", + "from_version": "current version", + "to_version": "new version" + } + ... +} +``` +| Field | Type | Description | +| --- | --- | --- | +| `info` | `string` | The message for actions. | +| `actions` | `list` | List of actions to show. | +| `actions.name` | `string` | The name/label of action. Will be shown on the action button | +| `actions.command` | `string` | Actions ID. Refers to the `adapter.json` | +| `updates` | `list` | List of available updates. | +| `updates.id` | `string` | The package ID. Plugin pass it as `{package_name}` placeholder | +| `updates.name` | `string` | The package name to be shown on the panel | +| `updates.icon` | `string` | (optional) Full path to the icon file | +| `updates.description` | `string` | (optional) The description of the package | +| `updates.from_version` | `string` | (optional) The current version of the package | +| `updates.to_version` | `string` | (optional) The new version of the package | diff --git a/system-updater/adapters/cargo/adapter.json b/system-updater/adapters/cargo/adapter.json new file mode 100644 index 00000000..d85a961a --- /dev/null +++ b/system-updater/adapters/cargo/adapter.json @@ -0,0 +1,9 @@ +{ + "name": "Cargo", + "enabled": true, + "dependencies": ["cargo", "python", "cargo-install-update", "awk", "bash"], + "check_command": "python {adapter_dir}/check.py", + "update_package_command": "cargo install \"{package_name}\"", + "update_all_command": "cargo-install-update install-update --all --git", + "actions": {} +} diff --git a/system-updater/adapters/cargo/check.py b/system-updater/adapters/cargo/check.py new file mode 100644 index 00000000..8d13be88 --- /dev/null +++ b/system-updater/adapters/cargo/check.py @@ -0,0 +1,86 @@ +import subprocess +import json +import re +import sys + +def get_updates_list(): + proc = subprocess.run(["bash", "-c", "cargo install-update --list 2>/dev/null | awk '$NF==\"Yes\"{print $1\"\t\"$2\"\t\"$3}'"], capture_output=True) + if proc.returncode != 0: + print(proc.stderr.decode(), file=sys.stderr) + exit(1) + stdout = proc.stdout.decode().strip() + for line in stdout.splitlines(): + parts = line.split("\t") + if len(parts) >= 3: + yield { + "name": parts[0].strip(), + "from_version": parts[1].strip(), + "to_version": parts[2].strip() + } + +def get_package_info(package_name): + try: + proc = subprocess.Popen( + ["pkgcli", "show", package_name, "--json", "--filter=newest;installed"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + stdout, _ = proc.communicate(input="1\n") + lines = stdout.splitlines() + if len(lines) == 1: + return json.loads(lines[0]) + for line in lines[1:]: + index = line.find("{") + if index != -1: + line = line[index:] + return json.loads(line) + return { + "summary": "", + "version": "" + } + except Exception as e: + return { + "summary": "", + "version": "" + } + +def get_package_description(package_name): + proc = subprocess.run(["cargo", "-q", "info", package_name], capture_output=True) + if proc.returncode != 0: + print(proc.stderr.decode(), file=sys.stderr) + exit(1) + stdout = proc.stdout.decode().strip() + lines = stdout.splitlines() + if len(lines) >= 2: + return lines[1].strip() # The second line usually contains the description + return "" + + +def package_kit_updates(): + + out = [] + + for update in get_updates_list(): + # Extract relevant information from the update dictionary + pkg_info = get_package_info(update.get("name")) + update_info = { + "id": update.get("name"), + "name": update.get("name"), + "icon": "", # Placeholder for icon path, as Cargo doesn't provide icons + "glyph": "brand-rust", # Placeholder glyph for Cargo packages + "description": get_package_description(update.get("name")), + "from_version": update.get("from_version", ""), + "to_version": update.get("to_version", "") + } + out.append(update_info) + + out.sort(key=lambda x: x["name"].lower()) + return { + "info": "", + "updates": out + } + +if __name__ == "__main__": + print(json.dumps(package_kit_updates(), indent=2, ensure_ascii=False)) diff --git a/system-updater/adapters/flatpak/adapter.json b/system-updater/adapters/flatpak/adapter.json new file mode 100644 index 00000000..d0273e25 --- /dev/null +++ b/system-updater/adapters/flatpak/adapter.json @@ -0,0 +1,9 @@ +{ + "name": "Flatpak", + "enabled": true, + "dependencies": ["flatpak", "python"], + "check_command": "python {adapter_dir}/check.py", + "update_package_command": "flatpak -y --noninteractive update \"{package_name}\"", + "update_all_command": "flatpak -y --noninteractive update", + "actions": {} +} diff --git a/system-updater/adapters/flatpak/check.py b/system-updater/adapters/flatpak/check.py new file mode 100644 index 00000000..4a068153 --- /dev/null +++ b/system-updater/adapters/flatpak/check.py @@ -0,0 +1,95 @@ +import subprocess +import json +import re +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +def get_icon_path(icon_name, size=48): + theme = Gtk.IconTheme.get_default() + icon_info = theme.lookup_icon(icon_name, size, Gtk.IconLookupFlags.USE_BUILTIN) + if icon_info: + return icon_info.get_filename() + return None + +def run_process(command, input_text=None): + try: + proc = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + stdout, _ = proc.communicate(input=input_text) + return stdout.splitlines() + except Exception as e: + return [] + +def parse_flatpak_list_output(lines): + apps = {} + the_caption_line = True + for line in lines: + if the_caption_line: + the_caption_line = False + continue + + parts = line.split("\t") + if len(parts) >= 4: + app_id = parts[0].strip() + apps[app_id] = { + "name": parts[1].strip(), + "description": parts[2].strip(), + "version": parts[3].strip() + } + return apps + +def get_real_flatpak_updates(): + # Run flatpak list --columns 'application,name,description,version' + list_lines = run_process(["flatpak", "list", "--app", "--columns", "application,name,description,version"]) + # Parse the output of flatpak list to create a mapping of app_id to its details + apps = parse_flatpak_list_output(list_lines) + + # Run flatpak list --app --columns 'application,name,description,version' + remote_ls_lines = run_process(["flatpak", "remote-ls", "--updates", "--columns", "application,name,description,version"]) + # Parse the output of flatpak remote-ls to create a mapping of app_id to its details + remote_ls_apps = parse_flatpak_list_output(remote_ls_lines) + + # Run flatpak update in non-interactive mode with an "n" (no) response + # This makes flatpak print the exact table and exit immediately + updates_lines = run_process(["flatpak", "update"], input_text="n\n") + + updates = [] + + # Parse the output of flatpak update to extract the list of updates + # And enrich them with the details from the apps mapping + for line in updates_lines: + line_str = line.strip() + + # Parse numbered lines (for example: "1. [i] org.mozilla.firefox stable flathub ...") + if re.match(r'^\d+\.', line_str): + # Remove the number and flags like [i], [u] + cleaned = re.sub(r'^\d+\.\s*(\[\w+\]\s*)?', '', line_str) + parts = cleaned.split() + + if len(parts) >= 1: + app_id = parts[0].strip() + if app_id in apps: + updates.append({ + "id": app_id, + "name": apps[app_id]["name"], + "description": apps[app_id]["description"], + "icon": get_icon_path(app_id), + "from_version": apps[app_id]["version"], + "to_version": remote_ls_apps[app_id]["version"] if app_id in remote_ls_apps and remote_ls_apps[app_id]["version"] != apps[app_id]["version"] else "" + }) + + updates.sort(key=lambda x: x["name"].lower()) + return { + "info": "", + "updates": updates + } + + +if __name__ == "__main__": + print(json.dumps(get_real_flatpak_updates(), indent=2, ensure_ascii=False)) diff --git a/system-updater/adapters/packagekit/adapter.json b/system-updater/adapters/packagekit/adapter.json new file mode 100644 index 00000000..fd8bf05f --- /dev/null +++ b/system-updater/adapters/packagekit/adapter.json @@ -0,0 +1,18 @@ +{ + "name": "PackageKit", + "enabled": true, + "dependencies": ["python", "pkgcli"], + "check_command": "python {adapter_dir}/check.py", + "update_package_command": "pkgcli -y -q update \"{package_name}\"", + "update_all_command": "pkgcli -y -q offline-update prepare", + "actions": { + "reboot": { + "command": "noctalia msg session reboot", + "check_after": false + }, + "cancel": { + "command": "pkgcli -y -q offline-update cancel", + "check_after": true + } + } +} diff --git a/system-updater/adapters/packagekit/check.py b/system-updater/adapters/packagekit/check.py new file mode 100644 index 00000000..2f82dbda --- /dev/null +++ b/system-updater/adapters/packagekit/check.py @@ -0,0 +1,143 @@ +import subprocess +import json +import re +import sys +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +def get_icon_path(icon_name, size=48): + theme = Gtk.IconTheme.get_default() + icon_info = theme.lookup_icon(icon_name, size, Gtk.IconLookupFlags.USE_BUILTIN) + if icon_info: + return icon_info.get_filename() + return None + +def icon_path(icon_name, size=48): + path = get_icon_path(icon_name, size) + if path: + return path + + parts = icon_name.split('-') + if len(parts) > 1: + # Try to find a more generic icon by removing the last part + generic_icon_name = '-'.join(parts[:-1]) + path = get_icon_path(generic_icon_name, size) + if path: + return path + if len(parts) > 2: + # Try to find a more generic icon by removing the last two parts + generic_icon_name = '-'.join(parts[:-2]) + path = get_icon_path(generic_icon_name, size) + if path: + return path + + # Fallback to a default icon if the specific icon is not found + #default_icon_name = "application-x-executable" # You can change this to any default icon you prefer + #return get_icon_path(default_icon_name, size) + return None # Return None if no icon is found + +def refresh_updates(): + proc = subprocess.run(["pkgcli", "refresh", "force"], capture_output=True) + if proc.returncode != 0: + print(proc.stderr.decode(), file=sys.stderr) + exit(1) + +def get_updates_list(): + proc = subprocess.run(["pkgcli", "list-updates", "--json"], capture_output=True) + if proc.returncode != 0: + print(proc.stderr.decode(), file=sys.stderr) + exit(1) + return [json.loads(line) for line in proc.stdout.decode().splitlines() if line.strip()] + +def get_package_info(package_name): + try: + proc = subprocess.Popen( + ["pkgcli", "show", package_name, "--json", "--filter=newest;installed"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + stdout, _ = proc.communicate(input="1\n") + lines = stdout.splitlines() + if len(lines) == 1: + return json.loads(lines[0]) + for line in lines[1:]: + index = line.find("{") + if index != -1: + line = line[index:] + return json.loads(line) + return { + "summary": "", + "version": "" + } + except Exception as e: + return { + "summary": "", + "version": "" + } + +def check_offline_update_status(): + proc = subprocess.run(["pkgcli", "-q", "offline-update", "status", "--json"], capture_output=True) + if proc.returncode != 0: + print(proc.stderr.decode(), file=sys.stderr) + exit(1) + stdout = proc.stdout.decode().strip() + lines = stdout.splitlines() + if len(lines) >= 1: + data = json.loads(lines[0]) # Validate JSON + if data.get("info", "").find("reboot") != -1: + return data.get("info", "") + return "" + + +def package_kit_updates(): + + info = check_offline_update_status() + if info != "": + return { + "info": info, + "info_key": "info_reboot_required", + "actions": [ + { + "name": "Cancel", + "tip": "Cancel the offline update process", + "command": "cancel" + }, + { + "name": "Reboot", + "tip": "Reboot the system to complete the offline update process", + "command": "reboot" + } + ], + "updates": [] + } + + refresh_updates() + + updates = get_updates_list() + + out = [] + + for update in updates: + # Extract relevant information from the update dictionary + pkg_info = get_package_info(update.get("name")) + update_info = { + "id": update.get("name"), + "name": update.get("name"), + "icon": icon_path(update.get("name")), + "description": pkg_info.get("summary", ""), + "from_version": pkg_info.get("version", ""), + "to_version": update.get("version", "") + } + out.append(update_info) + + out.sort(key=lambda x: x["name"].lower()) + return { + "info": "", + "updates": out + } + +if __name__ == "__main__": + print(json.dumps(package_kit_updates(), indent=2, ensure_ascii=False)) diff --git a/system-updater/panel.luau b/system-updater/panel.luau new file mode 100644 index 00000000..82e7130c --- /dev/null +++ b/system-updater/panel.luau @@ -0,0 +1,477 @@ +--!nonstrict + +local SKIN_PKG_TITLE_FONT_SIZE = 14 +local SKIN_PKG_DESCRIPTION_FONT_SIZE = 11 +local SKIN_PKG_VERSION_FONT_SIZE = 12 +local SKIN_PKG_ICON_SIZE = 48 + +local SKIN_ADAPTER_TITLE_FONT_SIZE = 16 + +local SKIN_ERRMSG_ICON_SIZE = 24 +local SKIN_ERRMSG_FONT_SIZE = 10 + +local SKIN_TASK_FONT_SIZE = 16 +local SKIN_TASK_ICON_SIZE = 20 + +local SKIN_TITLE_FONT_SIZE = 16 +local SKIN_HEAD_LINE_FONT_SIZE = 16 +local SKIN_DATE_FONT_SIZE = 12 + +local SKIN_UPDATE_LOG_FONT_SIZE = 10 + +local state = nil + +-- Forward declaration of the render function, so it can be called from other functions before it is defined. +local render = nil + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function phaseOf() + return state ~= nil and state.state or "idle" +end + +local function totalOf() + return state ~= nil and tonumber(state.total) or 0 +end + +local function busy() + local phase = phaseOf() + return phase ~= "idle" +end + +local function headline() + local phase = phaseOf() + if phase == "checking" then + return tr("status_checking"), "secondary" + elseif phase == "updating" then + return tr("status_updating"), "secondary" + else + if totalOf() > 0 then + return noctalia.trp("status_idle", totalOf(), {}), "primary" + end + if state ~= nil and state.last_check_time ~= nil then + return tr("status_clean"), "on_surface" + end + return tr("status_never_checked"), "on_surface" + end +end + +local function request(action, adapter, package_id) + noctalia.state.set("request", { + action = action, + adapter = adapter, + package = package_id, + }) +end + +local function adapter_action(adapter, action) + noctalia.state.set("adapter_action", { + action = action, + adapter = adapter + }) +end + +local function packageRow(item, adapter_name) + local children = { + ui.label({ text = item.name, fontSize = SKIN_PKG_TITLE_FONT_SIZE, color = "on_surface", flexGrow = 1, maxLines = 1, fontWeight = "bold" }), + } + if item.description ~= "" then + table.insert(children, ui.label({ text = item.description, fontSize = SKIN_PKG_DESCRIPTION_FONT_SIZE, color = "on_surface_variant", flexGrow = 1, maxLines = 2 })) + end + + if item.from_version ~= "" and item.to_version ~= "" then + table.insert(children, ui.label({ text = item.from_version .. " → " .. item.to_version, fontSize = SKIN_PKG_VERSION_FONT_SIZE, color = "secondary", flexGrow = 1, maxLines = 1, fontWeight = "bold" })) + elseif item.from_version ~= "" then + table.insert(children, ui.label({ text = item.from_version, fontSize = SKIN_PKG_VERSION_FONT_SIZE, color = "secondary", flexGrow = 1, maxLines = 1, fontWeight = "bold" })) + elseif item.to_version ~= "" then + table.insert(children, ui.label({ text = item.to_version, fontSize = SKIN_PKG_VERSION_FONT_SIZE, color = "secondary", flexGrow = 1, maxLines = 1, fontWeight = "bold" })) + end + + local row_childs = {} + if item.icon ~= "" and item.icon ~= nil then + table.insert(row_childs, ui.image({ path = item.icon, width = SKIN_PKG_ICON_SIZE, height = SKIN_PKG_ICON_SIZE, fit = "stretch" })) + else + table.insert(row_childs, ui.glyph({ name = item.glyph or "package", size = SKIN_PKG_ICON_SIZE, color = "on_surface" })) + end + table.insert(row_childs, ui.column({ flexGrow = 1 }, children)) + table.insert(row_childs, + ui.button({ + glyph = "download", + variant = "ghost", + tooltip = tr("tip_update_package"), + onClick = function() + request("update", adapter_name, item.id) + end, + }) +) + + return ui.row({ + paddingH = 8, + paddingV = 8, + borderWidth = 1, + fill = "surface_variant", + border = "outline", + radius = 12, + align = "start", + gap = 8 + }, row_childs) +end + +local function adapterRow(adapter) + local title_children = {} + table.insert(title_children, ui.label({ text = adapter.name, fontSize = SKIN_ADAPTER_TITLE_FONT_SIZE, color = "primary", flexGrow = 0, maxLines = 1 })) + if adapter.total > 0 then + table.insert(title_children, ui.label({ text = "[" .. tostring(adapter.total) .. "]", fontSize = SKIN_ADAPTER_TITLE_FONT_SIZE, flexGrow = 1, color = "primary", maxLines = 1 })) + else + table.insert(title_children, ui.spacer({ flexGrow = 1 })) + end + table.insert(title_children, ui.button({ + glyph = "refresh", + variant = "ghost", + tooltip = tr("tip_check_adapter", { adapter = adapter.name }), + onClick = function() + request("check", adapter.name) + end, + })) + if adapter.total > 0 then + table.insert(title_children, ui.button({ + glyph = "download", + variant = "ghost", + tooltip = tr("tip_update_adapter", { adapter = adapter.name }), + onClick = function() + request("update", adapter.name) + end, + })) + end + + local children = {} + + table.insert(children, ui.row({ gap = 8, align = "center" }, title_children)) + + if #adapter.error_msg > 0 then + for idx, msg in ipairs(adapter.error_msg) do + table.insert(children, + ui.column({ + paddingH = 8, + paddingV = 8, + borderWidth = 1, + fill = "error", + border = "on_error", + radius = 10, + flexGrow = 1, + gap = 8 + }, + { + ui.row({ gap = 8, align = "center" }, + { + ui.glyph({ name = "alert-circle", color = "on_error" }), + ui.label({ text = tr("error_phase_" .. msg.phase), color = "on_error", flexGrow = 1, maxLines = 1 }), + ui.button({ + glyph = "clipboard", + variant = "default", + controlSize = "sm", + tooltip = tr("tip_copy_to_clipboard"), + onClick = function() + noctalia.copyToClipboard(msg.message, "text/plain") + end, + }), + ui.button({ + glyph = "trash", + variant = "default", + controlSize = "sm", + tooltip = tr("tip_delete_error"), + onClick = function() + table.remove(adapter.error_msg, idx) + noctalia.state.set("del_error", { + adapter = adapter.name, + idx = idx + }) + end, + }) + }), + ui.label({ text = msg.message, color = "on_error", fontSize = SKIN_ERRMSG_FONT_SIZE, fontFamily = "monospace", flexGrow = 1, maxLines = 1 }), + } + )) + end + end + if adapter.info ~= nil and adapter.info ~= "" then + local info_column = {ui.label({ text = adapter.info, color = "primary", flexGrow = 1, maxLines = 4 })} + if #adapter.actions > 0 then + local action_row_children = {ui.spacer()} + for _, action in ipairs(adapter.actions) do + table.insert(action_row_children, ui.button({ + glyph = action.glyph, + text = action.name, + variant = "default", + tooltip = action.tip, + onClick = function() + adapter_action(adapter.name, action.command) + end, + })) + end + table.insert(info_column, ui.row({ gap = 8 }, action_row_children)) + end + table.insert(children, ui.row({ + paddingH = 8, + paddingV = 8, + borderWidth = 1, + fill = "surface_variant", + border = "outline", + radius = 10, + align = "start", + gap = 8 + }, + { + ui.glyph({ name = "info-circle", color = "on_surface_variant" }), + ui.column({ flexGrow = 1, gap = 4 }, info_column) + })) + end + + for _, package in ipairs(adapter.updates) do + table.insert(children, packageRow(package, adapter.name)) + end + + return ui.column({ + gap = 8 + }, children) +end + +local function checkTaskRow(task) + local state_options = { + waiting = { + glyph = "hourglass", + bk_color = "surface_variant", + text_color = "on_surface_variant" + }, + running = { + glyph = "refresh", + bk_color = "surface_variant", + text_color = "primary" + }, + done = { + glyph = "check", + bk_color = "surface_variant", + text_color = "on_surface_variant" + }, + error = { + glyph = "x", + bk_color = "error", + text_color = "on_error" + } + } + + local children = { + ui.label({ text = task.adapter, color = state_options[task.state].text_color, flexGrow = 1, maxLines = 1, fontWeight = "bold" }), + } + + if task.error_msg ~= "" then + table.insert(children, ui.label({ text = task.error_msg, fontSize = SKIN_ERRMSG_FONT_SIZE, color = state_options[task.state].text_color, flexGrow = 1, maxLines = 2 })) + end + + return ui.row({ + paddingH = 8, + paddingV = 8, + borderWidth = 1, + fill = state_options[task.state].bk_color, + border = "outline", + radius = 4, + align = "start", + gap = 4 + }, { + ui.glyph({ name = state_options[task.state].glyph, color = state_options[task.state].text_color }), + ui.column({ flexGrow = 1 }, children) + }) +end + +local function updateTaskRow(task) + local state_options = { + waiting = { + glyph = "hourglass", + bk_color = "surface_variant", + text_color = "on_surface_variant" + }, + running = { + glyph = "download", + bk_color = "surface_variant", + text_color = "on_surface" + }, + done = { + glyph = "check", + bk_color = "surface_variant", + text_color = "on_surface_variant" + }, + error = { + glyph = "x", + bk_color = "error", + text_color = "on_error" + } + } + + local title_row = ui.row({ gap = 8}, { + ui.glyph({ name = state_options[task.state].glyph, color = state_options[task.state].text_color }), + ui.label({ + text = task.adapter .. (task.package ~= nil and (" [" .. task.package .. "]") or ""), + color = state_options[task.state].text_color, + maxLines = 1, + fontWeight = "bold" + }), + }) + + return ui.column({ + paddingH = 8, + paddingV = 8, + borderWidth = 1, + fill = state_options[task.state].bk_color, + border = "outline", + radius = 12, + gap = 4 + }, { + title_row, + ui.label({ + text = task.stdout, + fontSize = SKIN_UPDATE_LOG_FONT_SIZE, + fontFamily = "monospace", + color = state_options[task.state].text_color, + flexGrow = 1, + maxLines = 1 }) + }) +end + +render = function() + + local headline_text, color = headline() + local phase = phaseOf() + local hasUpdates = totalOf() > 0 and phase == "idle" + + local children = { + -- Every child of the header carries a stable key, so the reconciler + -- matches roles instead of guessing by position and type. + ui.row({ key = "header", gap = 8, align = "center" }, { + ui.label({ + key = "title", + text = tr("title"), + fontSize = SKIN_TITLE_FONT_SIZE, + fontWeight = "bold", + color = "on_surface", + }), + ui.spacer({ key = "gap", flexGrow = 1 }), + ui.button({ + key = "header-check" .. (busy() and "-off" or ""), + glyph = "refresh", + variant = "ghost", + enabled = not busy(), + tooltip = tr("tip_check"), + onClick = function() + request("check") + end, + }), + -- Opens the settings window on this plugin's own page (the host + -- supplies the plugin id, so a plugin can only ever open its own). + -- The panel closes on the way; the engine keeps running. + ui.button({ + key = "settings", + glyph = "settings", + variant = "ghost", + tooltip = tr("tip_settings"), + onClick = function() + noctalia.openSettings() + end, + }), + ui.button({ + key = "close", + glyph = "close", + variant = "ghost", + tooltip = tr("tip_close"), + onClick = function() + panel.close() + end, + }), + }), + ui.label({ text = headline_text, color = color, maxLines = 2, fontSize = SKIN_HEAD_LINE_FONT_SIZE }), + } + if state ~= nil and not busy() and state.last_check_time ~= nil then + local last_check_time = os.date("%Y-%m-%d %H:%M:%S", state.last_check_time) + table.insert(children, ui.label({ text = tr("last_check_time", { time = last_check_time }), color = "on_surface_variant", maxLines = 1, fontSize = SKIN_DATE_FONT_SIZE })) + end + + + local rows = {} + local stickToBottom = false + if state ~= nil then + if state.state == "idle" then + -- Show the adapters and their updates when idle + for _, adapter in pairs(state.adapters) do + table.insert(rows, adapterRow(adapter)) + end + elseif state.state == "checking" then + -- Show the tasks when checking for updates + for _, task in ipairs(state.task_checking.finished) do + table.insert(rows, checkTaskRow(task)) + end + for _, task in ipairs(state.task_checking.pending) do + table.insert(rows, checkTaskRow(task)) + end + stickToBottom = true + elseif state.state == "updating" then + -- Show the tasks when updating + for _, task in ipairs(state.task_updating.finished) do + table.insert(rows, updateTaskRow(task)) + end + for _, task in ipairs(state.task_updating.pending) do + table.insert(rows, updateTaskRow(task)) + end + stickToBottom = true + end + end + + table.insert(children, ui.scroll({ flexGrow = 1, gap = 6, stickToBottom = stickToBottom }, rows)) + + -- The action buttons are always at the bottom, so they are easy to reach. + -- They are disabled when the system updater is busy checking for updates or applying updates. + table.insert(children, ui.row({ gap = 8, align = "center" }, { + ui.spacer({ flexGrow = 1 }), + ui.button({ + key = "check" .. (busy() and "-off" or ""), + glyph = "refresh", + text = tr("action_check"), + variant = "default", + enabled = not busy(), + tooltip = tr("tip_check"), + onClick = function() + request("check") + end, + }), + ui.button({ + key = "update" .. (hasUpdates and not busy() and "" or "-off"), + glyph = "download", + text = tr("action_update"), + variant = "primary", + enabled = hasUpdates and not busy(), + tooltip = tr("tip_update"), + onClick = function() + request("update") + end, + }), + })) + + panel.render(ui.column({ flexGrow = 1, gap = 4, align = "stretch" }, children)) +end + +function onOpen(_context) + state = noctalia.state.get("state") + render() +end + +noctalia.state.watch("state", function(value) + if type(value) ~= "table" then + return + end + + state = value + render() +end) + +state = noctalia.state.get("state") +render() diff --git a/system-updater/plugin.toml b/system-updater/plugin.toml new file mode 100644 index 00000000..dadf911d --- /dev/null +++ b/system-updater/plugin.toml @@ -0,0 +1,84 @@ +id = "tordex/system-updater" +name = "System Updates Manager" +version = "1.0.0" +plugin_api = 20 +author = "tordex" +license = "MIT" +dependencies = ["pkgcli", "cargo", "cargo-install-update", "python", "flatpak", "awk", "bash"] +tags = ["bar", "panel", "service", "system"] +icon = "package" +description = "Manage system updates (PackageKit, Flatpak, Cargo etc.)." + +[[setting]] +key = "enable_packagekit" +type = "bool" +label_key = "settings.enable_packagekit.label" +description_key = "settings.enable_packagekit.description" +default = true + +[[setting]] +key = "enable_flatpak" +type = "bool" +label_key = "settings.enable_flatpak.label" +description_key = "settings.enable_flatpak.description" +default = true + +[[setting]] +key = "enable_cargo" +type = "bool" +label_key = "settings.enable_cargo.label" +description_key = "settings.enable_cargo.description" +default = true + +[[setting]] +key = "auto_check_minutes" +type = "int" +label_key = "settings.auto_check_minutes.label" +description_key = "settings.auto_check_minutes.description" +default = 60 +min = 0 +max = 1440 + +[[setting]] +key = "adapters_folder" +type = "folder" +label_key = "settings.adapters_folder.label" +description_key = "settings.adapters_folder.description" +default = "" + +[[setting]] +key = "notify_on_updates" +type = "bool" +label_key = "settings.notify_on_updates.label" +description_key = "settings.notify_on_updates.description" +default = true + +[[widget]] +id = "status" +entry = "widget.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "package" + + [[widget.setting]] + key = "show_count" + type = "bool" + label_key = "settings.show_count.label" + description_key = "settings.show_count.description" + default = true + +[[service]] +id = "service" +entry = "service.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 500 +height = 600 +placement = "attached" +open_near_click = true diff --git a/system-updater/screenshots/panel.png b/system-updater/screenshots/panel.png new file mode 100644 index 00000000..ede15914 Binary files /dev/null and b/system-updater/screenshots/panel.png differ diff --git a/system-updater/service.luau b/system-updater/service.luau new file mode 100644 index 00000000..80844698 --- /dev/null +++ b/system-updater/service.luau @@ -0,0 +1,543 @@ +--!nonstrict + +-- List of adapters for checking and installing updates. Each adapter is a table with the following fields: +-- { +-- name = "adapter name", +-- enabled = true or false, +-- dependencies = {"list", "of", "dependencies"}, +-- check_command = "command to run to check for updates", +-- update_package_command = "command to run to update a single package". Use {package_name} as a placeholder for the package name. +-- If this field is empty, the adapter does not support updating single packages. +-- update_all_command = "command to run to update all packages", +-- phase = "ready" or "checking" or "updating". This field is filled in by the service and is not part of the adapter definition, +-- error_msg = "error message if any". This field is filled in by the service and is not part of the adapter definition, +-- total = number of updates available. This field is filled in by the service and is not part of the adapter definition, +-- updates = {"list", "of", "updates"}. This field is filled in by the service and is not part of the adapter definition, +-- } +local adapters = {} + +local startup_time = os.time() +local last_check_time = nil + +-- The adapter that should be rechecked after an update is completed. +-- This is used to recheck for updates after an update is completed, so that the user can see if there are any new updates available. +local recheck_adapter = {} + +-- Table with pending and finished tasks for checking updates. +-- task structure: +-- { +-- cmd = "command to run", +-- adapter = "adapter name", +-- phase = "check", +-- state = "waiting" or "running" or "done" or "error", +-- error_msg = "error message if any" +-- } +local task_checking = { + pending = {}, + finished = {} +} + +-- Table with pending and finished tasks for installing updates. +-- +local task_updating = { + pending = {}, + finished = {} +} + +local state = "idle" + +local function cfg(key) + return noctalia.getConfig(key) +end + +local function log(msg) + noctalia.log("[system-updater] " .. msg) +end + +local function err(msg) + noctalia.log("[ERR][system-updater] " .. msg) +end + +-- Load the adapters from the plugin's adapters directory. Each adapter is a JSON file with a name and a check command. +local function load_adapters() + local dirs = { + noctalia.pluginDir() .. "/adapters", + } + if cfg("adapters_folder") ~= nil and cfg("adapters_folder") ~= "" then + log("User adapters folder: " .. (cfg("adapters_folder") or "not set")) + if noctalia.fileExists(cfg("adapters_folder")) then + table.insert(dirs, cfg("adapters_folder")) + end + end + + local out = {} + + for _, dir in ipairs(dirs) do + local sub_dirs = noctalia.listDir(dir) + for _, dir_name in ipairs(sub_dirs) do + local file_info = noctalia.fileInfo(dir .. "/" .. dir_name) + if file_info.isDir then + local adapter_dir = dir .. "/" .. dir_name + local adapter_json_file = adapter_dir .. "/adapter.json" + if noctalia.fileExists(adapter_json_file) then + local str = noctalia.readFile(adapter_json_file) + local adapter = noctalia.json.decode(str) or nil + if adapter ~= nil and adapter.name ~= nil and adapter.check_command ~= nil then + if adapter.name == "PackageKit" and cfg("enable_packagekit") == false then + adapter.enabled = false + elseif adapter.name == "Flatpak" and cfg("enable_flatpak") == false then + adapter.enabled = false + elseif adapter.name == "Cargo" and cfg("enable_cargo") == false then + adapter.enabled = false + end + + for _, dep in ipairs(adapter.dependencies or {}) do + if not noctalia.commandExists(dep) then + err("Adapter " .. adapter.name .. " is missing dependency: " .. dep) + adapter.enabled = false + end + end + if adapter.enabled then + adapter.error_msg = "" + adapter.info = "" + adapter.show_actions = {} + adapter.total = 0 + adapter.updates = {} + adapter.check_command = string.gsub(adapter.check_command, "{adapter_dir}", adapter_dir) + adapter.update_package_command = string.gsub(adapter.update_package_command or "", "{adapter_dir}", adapter_dir) + adapter.update_all_command = string.gsub(adapter.update_all_command or "", "{adapter_dir}", adapter_dir) + out[adapter.name] = adapter + log("Loaded adapter: " .. adapter.name .. " path " .. adapter_dir) + else + log("Adapter " .. adapter.name .. " is disabled") + end + else + err("Invalid adapter definition in " .. adapter_json_file .. ". Adapter must have a name and a check_command.") + end + end + end + end + end + + return out +end + +-- Publish the current state to the plugin's state store. +-- This is used by the widget and panel to display the current state of the system updater. +function publish_state() + local items = {} + local updates_count = 0 + local error = false + for name, adapter in pairs(adapters) do + if adapter.enabled then + table.insert(items, { + name = name, + error_msg = adapter.error_msg, + total = adapter.total, + updates = adapter.updates, + info = adapter.info, + actions = adapter.show_actions, + }) + updates_count = updates_count + adapter.total + if adapter.error_msg ~= nil and #adapter.error_msg > 0 then + error = true + end + end + end + noctalia.state.set("state", { + state = state, + error = error, + total = updates_count, + adapters = items, + task_checking = task_checking, + task_updating = task_updating, + last_check_time = last_check_time, + }) +end + +-- Forward declarations of functions that are defined later in the file. +local start_check_queue +local start_update_queue + +-- Start the next command in the run queue, if any. +-- This is called after each command completes, and will start the next command in the queue. +local function next_check_task() + table.insert(task_checking.finished, task_checking.pending[1]) + table.remove(task_checking.pending, 1) + start_check_queue() +end + +-- Start the next command in the run queue, if any. +--This is called after each command completes, and will start the next command in the queue. +local function next_update_task() + table.insert(task_updating.finished, task_updating.pending[1]) + table.remove(task_updating.pending, 1) + start_update_queue() +end + +-- Start the next command in the run queue, if any. +-- This is called after each command completes, and will start the next command in the queue. +start_check_queue = function() + if #task_checking.pending == 0 then + state = "idle" + if cfg("notify_on_updates") == true then + local has_errors = false + local body = "" + for idx, task in ipairs(task_checking.finished) do + if idx > 1 then + body = body .. "\n" + end + if task.state == "done" then + if #adapters[task.adapter].show_actions > 0 then + body = body .. noctalia.tr("notify_check_body_action", {adapter = task.adapter}) + else + if adapters[task.adapter].total > 0 then + body = body .. noctalia.tr("notify_check_body_found", {adapter = task.adapter, total = adapters[task.adapter].total}) + else + body = body .. noctalia.tr("notify_check_body_success", {adapter = task.adapter}) + end + end + elseif task.state == "error" then + has_errors = true + body = body .. noctalia.tr("notify_check_body_error", {adapter = task.adapter}) + end + end + if has_errors then + noctalia.notifyError(noctalia.tr("notify_check_error_title"), body) + else + noctalia.notify(noctalia.tr("notify_check_finished_title"), body) + end + end + publish_state() + return + end + local cmd = task_checking.pending[1].cmd + local adapter = task_checking.pending[1].adapter + task_checking.pending[1].state = "running" + publish_state() + noctalia.runAsync(cmd, function (result) + if result.timedOut or result.exitCode ~= 0 then + task_checking.pending[1].state = "error" + if result.timedOut then + table.insert(adapters[adapter].error_msg, { + phase = task_checking.pending[1].phase, + message = noctalia.tr("error_timeout") + }) + task_checking.pending[1].error_msg = noctalia.tr("error_timeout") + else + table.insert(adapters[adapter].error_msg, { + phase = task_checking.pending[1].phase, + message = result.stderr + }) + task_checking.pending[1].error_msg = result.stderr + end + adapters[adapter].total = 0 + else + task_checking.pending[1].state = "done" + local data = noctalia.json.decode(result.stdout) or {info = "", updates = {}} + adapters[adapter].updates = data.updates + adapters[adapter].info = data.info or "" + adapters[adapter].show_actions = data.actions or {} + adapters[adapter].total = #adapters[adapter].updates + for _, action in ipairs(adapters[adapter].show_actions) do + local label_key = "adapter_actions." .. adapter:lower() .. "_" .. action.command .. ".label" + local tip_key = "adapter_actions." .. adapter:lower() .. "_" .. action.command .. ".tip" + local label_text = noctalia.tr(label_key) + local tip_text = noctalia.tr(tip_key) + if label_text ~= label_key then + action.name = label_text + end + if tip_text ~= tip_key then + action.tip = tip_text + end + end + -- If the adapter returned an info_key, we will try to translate it and use it as the info text. + if data.info_key ~= nil then + local info_key = "adapter_actions." .. adapter:lower() .. "_" .. data.info_key .. ".label" + local info_text = noctalia.tr(info_key) + if info_text ~= info_key then + adapters[adapter].info = noctalia.tr(info_key) or adapters[adapter].info + end + end + end + publish_state() + next_check_task() + end, 60000) +end + +-- Start a check for updates. This will run the check command for each enabled adapter. +local function start_check(adapter_names) + if adapters == nil then + err("Adapters are empty") + return + end + if state ~= "idle" then + err("System updater is busy") + return + end + + table.clear(task_checking.pending) + table.clear(task_checking.finished) + + for name, adapter in pairs(adapters) do + if adapter.enabled and (adapter_names == nil or table.find(adapter_names, name)) then + adapters[name].error_msg = {} + adapters[name].total = 0 + table.insert(task_checking.pending, { + cmd = string.gsub(adapter.check_command, "{plugin_dir}", noctalia.pluginDir()), + adapter = name, + phase = "check", + state = "waiting", + error_msg = "" + }) + end + end + + if #task_checking.pending > 0 then + state = "checking" + last_check_time = os.time() + publish_state() + start_check_queue() + else + state = "idle" + publish_state() + end +end + +-- Helper function to check if a string starts with a given prefix. +local function starts_with(str: string, prefix: string): boolean + return string.sub(str, 1, #prefix) == prefix +end + +-- Start the next command in the run queue, if any. This is called after each command completes, and will start the next command in the queue. +start_update_queue = function() + if #task_updating.pending == 0 then + state = "idle" + if cfg("notify_on_updates") == true then + local has_errors = false + local body = "" + for idx, task in ipairs(task_updating.finished) do + if idx > 1 then + body = body .. "\n" + end + if task.state == "done" then + body = body .. noctalia.tr("notify_update_body_success", {adapter = task.adapter}) + table.insert(recheck_adapter, task.adapter) + elseif task.state == "error" then + has_errors = true + body = body .. noctalia.tr("notify_update_body_error", {adapter = task.adapter}) + end + end + if has_errors then + noctalia.notifyError(noctalia.tr("notify_update_error_title"), body) + else + noctalia.notify(noctalia.tr("notify_update_finished_title"), body) + end + end + start_check(recheck_adapter) + return + end + + local cmd = task_updating.pending[1].cmd + local adapter = task_updating.pending[1].adapter + task_updating.pending[1].state = "running" + publish_state() + noctalia.runStream(cmd, function(line: string) + local start_prefix = "tordex/system-updater:pid:" + local done_prefix = "tordex/system-updater:done:" + + if starts_with(line, start_prefix) then + local pid = tonumber(string.sub(line, #start_prefix + 1)) + task_updating.pending[1].pid = pid + publish_state() + elseif starts_with(line, done_prefix) then + local exitCode = tonumber(string.sub(line, #done_prefix + 1)) + log("Update task finished with exit code " .. tostring(exitCode)) + if exitCode ~= 0 then + task_updating.pending[1].state = "error" + table.insert(adapters[adapter].error_msg, { + phase = "update", + message = task_updating.pending[1].stdout + }) + else + task_updating.pending[1].state = "done" + table.insert(recheck_adapter, adapter) + end + publish_state() + next_update_task() + else + task_updating.pending[1].stdout = task_updating.pending[1].stdout .. line .. "\n" + publish_state() + end + end) +end + +-- Wrap the update command to capture the PID and exit code, so that we can track the progress of the update. +local function wrap_update_command(cmd) + return "echo tordex/system-updater:pid:$$;(" .. cmd .. ") 2>&1;echo tordex/system-updater:done:$?" +end + +-- Validate the package name to ensure it does not contain any invalid characters. +-- This is used to prevent command injection when updating a single package. +local function validate_package_name(package_name) + if string.find(package_name, "[^%w%+%-%._:]") then + err("Package name contains invalid characters: " .. package_name) + return false + end + return true +end + +-- Start an update for the specified adapter and package. If no adapter or package is specified, all enabled adapters will be updated. +local function start_update(adapter_name, package) + if adapters == nil then + err("Adapters are empty") + return + end + if state ~= "idle" then + err("System updater is busy") + return + end + + table.clear(task_updating.pending) + table.clear(task_updating.finished) + table.clear(recheck_adapter) + + if adapter_name ~= nil and package ~= nil then + if not validate_package_name(package) then + return + end + -- Update a single package using the specified adapter + local adapter = adapters[adapter_name] + if adapter ~= nil and adapter.enabled then + local cmd = string.gsub(adapter.update_package_command, "{package_name}", package) + cmd = string.gsub(cmd, "{plugin_dir}", noctalia.pluginDir()) + if cmd == nil or cmd == "" then + err("Adapter " .. adapter_name .. " does not support updating single packages") + return + end + adapters[adapter_name].error_msg = {} + cmd = wrap_update_command(cmd) + table.insert(task_updating.pending, { + cmd = cmd, + adapter = adapter_name, + package = package, + state = "waiting", + stdout = "", + pid = nil + }) + end + else + -- Update all packages for all or specified enabled adapters + for name, adapter in pairs(adapters) do + if adapter.enabled and adapter.total > 0 and adapter.update_all_command ~= nil and (adapter_name == nil or name == adapter_name) then + local cmd = string.gsub(adapter.update_all_command, "{plugin_dir}", noctalia.pluginDir()) + if cmd == nil or cmd == "" then + err("Adapter " .. name .. " does not support updating all packages") + continue + end + adapters[name].error_msg = {} + cmd = wrap_update_command(cmd) + table.insert(task_updating.pending, { + cmd = cmd, + adapter = name, + package = package, + state = "waiting", + stdout = "", + pid = nil + }) + end + end + end + + if #task_updating.pending > 0 then + state = "updating" + publish_state() + start_update_queue() + else + state = "idle" + publish_state() + end +end + +local function handle(action, adapter, package) + if action == "check" then + if adapter ~= nil then + start_check({adapter}) + else + start_check() + end + elseif action == "update" then + start_update(adapter, package) + end +end + +-- Scriptable control: +-- noctalia msg plugin tordex/system-updater:service all check +-- noctalia msg plugin tordex/system-updater:service all update +function onIpc(event, _payload) + handle(event, nil, nil) +end + + +noctalia.state.watch("request", function(value) + handle(value.action, value.adapter, value.package) +end) + +noctalia.state.watch("del_error", function(value) + local adapter = value.adapter + local idx = value.idx + if adapters[adapter] ~= nil and adapters[adapter].error_msg[idx] ~= nil then + table.remove(adapters[adapter].error_msg, idx) + publish_state() + end +end) + +noctalia.state.watch("adapter_action", function(value) + local adapter = value.adapter + local cmd = adapters[adapter].actions[value.action].command + local check_after = adapters[adapter].actions[value.action].check_after + state = "action" + publish_state() + noctalia.runAsync(cmd, function (result) + if result.timedOut or result.exitCode ~= 0 then + table.insert(adapters[adapter].error_msg, { + phase = "action", + message = result.stderr + }) + state = "idle" + publish_state() + else + if check_after then + state = "idle" + start_check({adapter}) + else + state = "idle" + publish_state() + end + end + end, 60000) +end) + +function update() + local auto_check_minutes = cfg("auto_check_minutes") or 0 + if auto_check_minutes > 10 then + if last_check_time == nil and startup_time ~= nil then + local now = os.time() + -- If the system updater has never checked for updates, we check for updates 5 minutes after startup. + if os.difftime(now, startup_time) >= 5 * 60 then + startup_time = nil + start_check() + end + elseif last_check_time ~= nil then + local now = os.time() + if os.difftime(now, last_check_time) >= 60 * auto_check_minutes then + last_check_time = now + start_check() + end + end + end +end + +noctalia.setUpdateInterval(1000) +adapters = load_adapters() +publish_state() diff --git a/system-updater/thumbnail.webp b/system-updater/thumbnail.webp new file mode 100644 index 00000000..41cd2490 Binary files /dev/null and b/system-updater/thumbnail.webp differ diff --git a/system-updater/translations/en.json b/system-updater/translations/en.json new file mode 100644 index 00000000..15056a86 --- /dev/null +++ b/system-updater/translations/en.json @@ -0,0 +1,84 @@ +{ + "action_check": "Check Updates", + "action_update": "Update", + "settings": { + "glyph": { + "description": "The glyph shown for the system updater widget on the bar.", + "label": "Bar glyph" + }, + "show_count": { + "description": "Show the number of pending updates next to the bar glyph.", + "label": "Show the update count" + }, + "auto_check_minutes": { + "description": "Check for updates automatically every N minutes. if <= 10 never checks on its own — nothing runs until you ask for it.", + "label": "Auto-check interval (minutes)" + }, + "adapters_folder": { + "description": "The folder where adapters definitions are stored. One adapter per subfolder. Each adapter folder must contain an adapter.json with the adapter definition.", + "label": "Adapters folder" + }, + "notify_on_updates": { + "description": "Send a desktop notification when a check finds packages to upgrade or after applying updates.", + "label": "Notify when updates are found or applied" + }, + "enable_packagekit": { + "description": "Enable the PackageKit adapter for managing system updates.", + "label": "Enable PackageKit adapter" + }, + "enable_flatpak": { + "description": "Enable the Flatpak adapter for managing system updates.", + "label": "Enable Flatpak adapter" + }, + "enable_cargo": { + "description": "Enable the Cargo adapter for managing system updates.", + "label": "Enable Cargo adapter" + } + }, + "status_idle": { + "one": "1 package to upgrade", + "other": "{count} packages to upgrade" + }, + "adapter_actions": { + "packagekit_cancel": { + "label": "Cancel", + "tip": "Cancel the offline update process" + }, + "packagekit_reboot": { + "label": "Reboot and update", + "tip": "Reboot the system to complete the offline update process" + }, + "packagekit_info_reboot_required": { + "label": "Offline update is triggered. Reboot is required to complete the update process." + } + }, + "status_clean": "Everything is up to date", + "status_never_checked": "Updates have never been checked", + "last_check_time": "Last checked: {time}", + "status_checking": "Checking for updates…", + "status_updating": "Downloading and applying updates…", + "tip_check": "Check for updates now", + "tip_check_adapter": "{adapter}: Check for updates now", + "tip_close": "Close", + "tip_settings": "Plugin settings", + "tip_update": "Update pending packages", + "tip_update_adapter": "{adapter}: Update pending packages", + "tip_delete_error": "Delete this error message", + "title": "System Updates Manager", + "error_timeout": "The command timed out.", + "tip_copy_to_clipboard": "Copy text to clipboard", + "tip_update_package": "Update the selected package", + "error_phase_update": "Error occurred while updating", + "error_phase_check": "Error occurred while checking for updates", + "error_phase_action": "Error occurred while performing the action", + "notify_update_finished_title": "System Updates Manager: Updates applied", + "notify_update_error_title": "System Updates Manager: Error applying updates", + "notify_update_body_success": "✔ {adapter}: done.", + "notify_update_body_error": "🗙 {adapter}: failed.", + "notify_check_finished_title": "System Updates Manager: Check completed", + "notify_check_error_title": "System Updates Manager: Error checking for updates", + "notify_check_body_found": "✔ {adapter}: {total} packages to upgrade.", + "notify_check_body_success": "✔ {adapter}: up to date.", + "notify_check_body_error": "🗙 {adapter}: failed.", + "notify_check_body_action": "✔ {adapter}: done, action required." +} diff --git a/system-updater/widget.luau b/system-updater/widget.luau new file mode 100644 index 00000000..6cc55a44 --- /dev/null +++ b/system-updater/widget.luau @@ -0,0 +1,62 @@ +--!nonstrict + +local state = nil + +local function log(msg) + noctalia.log("[system-updater] " .. msg) +end + +local function err(msg) + noctalia.log("[ERR][system-updater] " .. msg) +end + +-- Updates are "pending" only while they are worth interrupting the user for: +-- a dismissed result stays visible in the panel but takes the bar back to its +-- resting colour. +local function pending() + return state ~= nil + and state.state == "idle" + and (state.total or 0) > 0 +end + +local function render() + barWidget.setGlyph(noctalia.getConfig("glyph")) + + local phase = state ~= nil and state.state or "idle" + local error = state ~= nil and state.error or false + if error then + barWidget.setGlyphColor("error") + elseif phase ~= "idle" then + barWidget.setGlyphColor("secondary") + elseif pending() then + barWidget.setGlyphColor("primary") + else + barWidget.setGlyphColor("on_surface") + end + + if pending() and noctalia.getConfig("show_count") == true then + barWidget.setText(tostring(state.total)) + else + barWidget.setText("") + end +end + +function update() + render() +end + +function onClick() + noctalia.togglePanel("tordex/system-updater:panel") +end + +-- Live updates from the plugin's background service. +noctalia.state.watch("state", function(value) + if type(value) == "table" then + state = value + render() + end +end) + +noctalia.setUpdateInterval(1000) +state = noctalia.state.get("state") +render()