diff --git a/phone-connect/README.md b/phone-connect/README.md new file mode 100644 index 0000000..048bfe9 --- /dev/null +++ b/phone-connect/README.md @@ -0,0 +1,75 @@ +# Phone Connect + +Control your KDE Connect-paired phone from the Noctalia bar. + +## Plugin + +| Entry | Type | Id | +|-------|------|----| +| `icefish/phone-connect` | plugin | — | +| `bar` | widget | `icefish/phone-connect:bar` | +| `tile` | shortcut | `icefish/phone-connect:tile` | +| `details` | panel | `icefish/phone-connect:details` | +| `details_floating` | panel | `icefish/phone-connect:details_floating` | +| `details_widget` | panel | `icefish/phone-connect:details_widget` | +| `kde` | service | `icefish/phone-connect:kde` | + +## Usage + +Add the bar widget and control-center tile from Settings, then click either to open the details panel. + +Open panels from the shell: + +``` +noctalia msg panel-toggle icefish/phone-connect:details +noctalia msg panel-toggle icefish/phone-connect:details_floating +noctalia msg panel-toggle icefish/phone-connect:details_widget +``` + +Send IPC events to the service: + +``` +noctalia msg plugin icefish/phone-connect:kde all refresh +noctalia msg plugin icefish/phone-connect:kde all cmd '{"op":"ring","device":""}' +``` + +## Features + +- **Device status** — battery level, charging state, network type (5G/LTE), pairing +- **Quick actions** — ring, ping, send clipboard, share text/files/URLs, SMS, SFTP browser +- **Media control** — play/pause/skip/seek, album art, now-playing (MPRIS) +- **Customization** — per-device image and alias, three panel placements +- **Language** — English / Simplified Chinese + +## Requirements + +- `kdeconnect` — KDE Connect daemon and CLI +- `gdbus` — ships with glib2 +- `sshfs` — optional, for phone file browsing + +## Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| State Update Interval | 30 | Seconds between device refreshes | +| Show Charging Fill | true | Highlight bar widget when charging | +| Show Clipboard Action | true | Show clipboard button in panel | +| Device Image | — | Custom image for selected device | +| Device Alias | Your-Phone | Custom display name | +| Language | en | English / 简体中文 | +| Panel Placement | attached | Attached / Floating center / Below widget | + +## IPC + +| Event | Payload | Description | +|-------|---------|-------------| +| `refresh` | — | Full device refresh | +| `cmd` | JSON `{"op":"…","device":"…"}` | Execute an operation | + +Supported ops: `ring`, `ping`, `clipboard`, `share_text`, `share_url`, `share_file`, `pair`, `accept`, `reject`, `unpair`, `browse`, `sms_send`, `launch_sms_app`, `media`, `media_seek`, `select`, `refresh`, `set_image`. + +## Notes + +- Communicates with KDE Connect via DBus (`gdbus call`) and CLI (`kdeconnect-cli`) +- Persists user preferences to `pluginDataDir()/state.json` +- No external network requests; all logic runs locally diff --git a/phone-connect/panel.luau b/phone-connect/panel.luau new file mode 100644 index 0000000..bf42beb --- /dev/null +++ b/phone-connect/panel.luau @@ -0,0 +1,378 @@ +-- panel.luau +-- Phone Connect - details panel (layered info-style UI, 720x600). +-- +-- Layout: header + device hero + info grid span the top (full width); below +-- them a two-column row: left (flex, scrollable) holds media control + SMS +-- composer; right (fixed width) holds action buttons stacked vertically. +-- Reads state from the service entry; sends commands via "pc.cmd". + + +-- Local translation: reads from pc.trTable (published by service) so users +-- can switch language at runtime, unlike noctalia.tr() which follows system locale. +local function t(key) + local table = noctalia.state.get("pc.trTable") or {} + return table[key] or key +end + +local PLUGIN_ID = "icefish/phone-connect" + +-- ── Command channel ───────────────────────────────────────────────────────── +local function sendCmd(cmd) + -- time-based seq prevents silent drops after hot-reload + cmd.seq = math.floor(os.clock() * 1000000) + noctalia.state.set("pc.cmd", cmd) +end + +-- ── SMS composer local state (ui.input is uncontrolled) ───────────────────── +local smsDestination = "" +local smsMessage = "" + +-- ── Helpers ───────────────────────────────────────────────────────────────── +local function glyphForType(t) + if t == "tablet" then return "device-tablet" end + if t == "laptop" then return "device-laptop" end + if t == "desktop" or t == "computer" then return "device-desktop" end + if t == "tv" then return "device-tv" end + return "device-mobile" +end + +local function batteryGlyph(charge, charging) + if charging then return "battery-charging" end + if type(charge) ~= "number" then return "battery" end + if charge >= 90 then return "battery" end + if charge >= 60 then return "battery-3" end + if charge >= 30 then return "battery-2" end + if charge > 0 then return "battery-1" end + return "battery-off" +end + +local function infoCell(label, value, glyph) + return ui.column({ gap = 2, padding = 6, radius = 6, fill = "surface/0.5", flexGrow = 1 }, { + ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = glyph, size = 13, color = "on_surface_variant" }), + ui.label({ text = label, fontSize = 10, color = "on_surface_variant" }), + }), + ui.label({ text = value, fontSize = 13, fontWeight = "medium", color = "on_surface" }), + }) +end + +-- ── Device hero card (top, full width) ────────────────────────────────────── +local function heroCard(d) + local reachable = d.isReachable == true + local paired = d.isPaired == true + local aliasMap = noctalia.state.get("pc.aliasMap") or {} + local name = aliasMap[d.id] or "Your-Phone" + local glyph = glyphForType(d.type) + local imgMap = noctalia.state.get("pc.imageMap") or {} + local imgPath = imgMap[d.id] + -- verify custom image exists + if imgPath and imgPath ~= "" and not noctalia.fileExists(imgPath) then + imgPath = nil + end + local statusText, statusColor + if not reachable then + statusText = paired and t("status.offline") or t("status.not_paired") + statusColor = "on_surface_variant" + else + statusText = t("status.connected") + statusColor = "primary" + end + local charge = d.batteryCharge + local chargeText = (type(charge) == "number" and charge >= 0) + and (tostring(charge) .. "%") or "--" + local chargeColor = "on_surface" + if type(charge) == "number" and charge <= 20 then chargeColor = "error" end + + return ui.column({ gap = 8, padding = 14, radius = 10, fill = "surface/0.7", + border = "primary/0.2", borderWidth = 1 }, { + ui.row({ gap = 14, align = "center" }, { + imgPath and ui.image({ path = imgPath, width = 52, height = 52, radius = 26, fit = "cover" }) + or ui.box({ width = 52, height = 52, radius = 26, fill = "primary/0.15" }, { + ui.glyph({ name = glyph, size = 28, color = "primary" }), + }), + ui.column({ gap = 4, flexGrow = 1 }, { + ui.label({ text = name, fontSize = 17, fontWeight = "bold", color = "on_surface" }), + ui.box({ radius = 10, fill = statusColor .. "/0.15", padding = 4 }, { + ui.label({ text = statusText, fontSize = 11, color = statusColor }), + }), + }), + ui.column({ gap = 2, align = "center" }, { + ui.glyph({ name = batteryGlyph(charge, d.batteryCharging), size = 22, + color = chargeColor }), + ui.label({ text = chargeText, fontSize = 13, fontWeight = "bold", color = chargeColor }), + }), + }), + }) +end + +-- ── Info grid (top, full width) ────────────────────────────────────────────── +local function infoGrid(d) + local charge = d.batteryCharge + local chargeVal = (type(charge) == "number" and charge >= 0) + and (tostring(charge) .. "%" .. (d.batteryCharging and " ⚡" or "")) or "--" + local netVal = (d.networkType and d.networkType ~= "") + and (d.networkType .. (type(d.networkStrength) == "number" + and d.networkStrength >= 0 and (" • " .. tostring(d.networkStrength) .. "/4") or "")) + or "--" + local pairVal = d.isPaired and t("panel.paired") or t("panel.unpaired") + local typeVal = d.type or t("panel.unknown") + return ui.row({ gap = 8 }, { + infoCell(t("panel.battery"), chargeVal, "battery"), + infoCell(t("panel.network"), netVal, "antenna"), + infoCell(t("panel.pairing"), pairVal, "link"), + infoCell(t("panel.type"), typeVal, "device-mobile"), + }) +end + +-- ── Right column: action buttons stacked vertically ───────────────────────── +local function vActionBtn(text, glyph, onClick, variant) + return ui.button({ text = text, glyph = glyph, variant = variant or "outline", + controlSize = "md", onClick = onClick }) +end + +local function rightColumn(d) + local reachable = d.isReachable == true + local paired = d.isPaired == true + local btns = {} + if reachable and paired then + table.insert(btns, vActionBtn(t("action.ring"), "bell-ringing", + function() sendCmd({ op = "ring", device = d.id }) end, "primary")) + table.insert(btns, vActionBtn(t("action.ping"), "message", + function() sendCmd({ op = "ping", device = d.id }) end)) + table.insert(btns, vActionBtn(t("action.browse"), "folder", + function() sendCmd({ op = "browse", device = d.id }) end)) + table.insert(btns, vActionBtn(t("action.sms"), "message-2", + function() sendCmd({ op = "launch_sms_app", device = d.id }) end)) + if noctalia.getConfig("enable_clipboard_action") then + table.insert(btns, vActionBtn(t("action.clipboard"), "clipboard", + function() sendCmd({ op = "clipboard", device = d.id }) end)) + end + table.insert(btns, vActionBtn(t("action.share"), "share", + function() + local clip = noctalia.clipboardText() + if clip then sendCmd({ op = "share_text", device = d.id, text = clip }) end + end)) + table.insert(btns, vActionBtn(t("action.unpair"), "link-off", + function() sendCmd({ op = "unpair", device = d.id }) end, "ghost")) + elseif not paired then + table.insert(btns, vActionBtn(t("action.pair"), "link", + function() sendCmd({ op = "pair", device = d.id }) end, "primary")) + else + table.insert(btns, vActionBtn(t("action.unpair"), "link-off", + function() sendCmd({ op = "unpair", device = d.id }) end, "ghost")) + end + return ui.column({ width = 110, gap = 8, padding = 8, align = "stretch" }, btns) +end + +-- ── Left column: media + SMS (scrollable) ────────────────────────────────── +local function fmtTime(ms) + if type(ms) ~= "number" or ms <= 0 then return "0:00" end + local s = math.floor(ms / 1000) + local m = math.floor(s / 60) + s = s % 60 + return string.format("%d:%02d", m, s) +end + +local function mediaSection(d) + local title = d.mediatitle + if not title or title == "" then return nil end + local artist = d.mediaartist or "" + local album = d.mediaalbum or "" + local playing = d.mediaisPlaying == true + local volume = tonumber(d.mediavolume) or 0 + local length = tonumber(d.medialength) or 0 + local position = tonumber(d.mediaposition) or 0 + local canSeek = d.mediacanSeek == true + local artUrl = d.mediaArtPath + + -- only use art if the file exists (phone path via SFTP mount) + if artUrl and artUrl ~= "" and not noctalia.fileExists(artUrl) then + artUrl = nil + end + + local header = ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = "music", size = 13, color = "primary" }), + ui.label({ text = t("panel.now_playing"), fontSize = 10, + color = "primary", fontWeight = "bold", flexGrow = 1 }), + }) + + local titleBlock + if artUrl then + titleBlock = ui.row({ gap = 8, align = "center" }, { + ui.image({ path = artUrl, width = 32, height = 32, radius = 4, fit = "cover" }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = title, fontSize = 12, fontWeight = "medium", color = "on_surface" }), + ui.label({ text = artist, fontSize = 10, color = "on_surface_variant" }), + ui.label({ text = album, fontSize = 9, color = "on_surface_variant" }), + }), + }) + else + titleBlock = ui.column({ gap = 1 }, { + ui.label({ text = title, fontSize = 12, fontWeight = "medium", color = "on_surface" }), + ui.label({ text = artist, fontSize = 10, color = "on_surface_variant" }), + ui.label({ text = album, fontSize = 9, color = "on_surface_variant" }), + }) + end + + local transport = ui.row({ gap = 4, align = "center" }, { + ui.button({ glyph = "player-skip-back", variant = "ghost", controlSize = "sm", + onClick = function() sendCmd({ op = "media", device = d.id, action = "Previous" }) end }), + ui.button({ glyph = playing and "player-pause" or "player-play", + variant = "primary", controlSize = "sm", + onClick = function() sendCmd({ op = "media", device = d.id, action = "PlayPause" }) end }), + ui.button({ glyph = "player-skip-forward", variant = "ghost", controlSize = "sm", + onClick = function() sendCmd({ op = "media", device = d.id, action = "Next" }) end }), + ui.button({ glyph = "player-stop", variant = "ghost", controlSize = "sm", + onClick = function() sendCmd({ op = "media", device = d.id, action = "Stop" }) end }), + }) + + local progress + if canSeek and length > 0 then + local seekPos = position + progress = ui.column({ gap = 2 }, { + ui.slider({ min = 0, max = length, step = 1000, value = position, + controlSize = "sm", + onChange = function(v) + seekPos = tonumber(v) or position + end, + onDragEnd = function() + sendCmd({ op = "media_seek", device = d.id, offset = tostring(seekPos) }) + end }), + ui.row({ justify = "space_between" }, { + ui.label({ text = fmtTime(position), fontSize = 9, color = "on_surface_variant" }), + ui.label({ text = fmtTime(length), fontSize = 9, color = "on_surface_variant" }), + }), + }) + end + + local children = { header, titleBlock, transport } + if progress then table.insert(children, progress) end + return ui.column({ gap = 6, padding = 8, radius = 8, fill = "primary/0.08" }, children) +end + +local function smsComposer(d) + return ui.column({ gap = 8, padding = 12, radius = 8, fill = "surface/0.5", + border = "on_surface/0.1", borderWidth = 1 }, { + ui.label({ text = t("panel.sms"), fontSize = 12, fontWeight = "bold", + color = "on_surface" }), + ui.input({ key = "sms-dest", placeholder = t("panel.sms_dest"), + value = smsDestination, controlSize = "sm", + onChange = function(v) smsDestination = v end }), + ui.input({ key = "sms-msg", placeholder = t("panel.sms_msg"), + value = smsMessage, controlSize = "sm", + onChange = function(v) smsMessage = v end }), + ui.button({ text = t("panel.sms_send"), glyph = "send", + variant = "primary", controlSize = "sm", + onClick = function() + if smsDestination ~= "" and smsMessage ~= "" then + sendCmd({ op = "sms_send", device = d.id, + destination = smsDestination, text = smsMessage }) + smsMessage = "" + render() + end + end }), + }) +end + +local function leftColumn(d) + local items = {} + local media = mediaSection(d) + if media then table.insert(items, media) end + if d.isReachable and d.isPaired then + table.insert(items, smsComposer(d)) + end + -- if nothing to show, a placeholder so the column isn't empty + if #items == 0 then + table.insert(items, ui.column({ gap = 8, padding = 20, align = "center" }, { + ui.glyph({ name = "music-off", size = 28, color = "on_surface_variant" }), + ui.label({ text = t("panel.no_media"), color = "on_surface_variant" }), + })) + end + return ui.column({ width = 304, flexGrow = 1 }, { + ui.scroll({ flexGrow = 1, gap = 12, padding = 0 }, items), + }) +end + +-- ── Device switcher (bottom, full width, only if >1 device) ───────────────── +local function switcher(devices, order, sel) + if #order <= 1 then return nil end + local items = {} + for _, id in ipairs(order) do + local d = devices[id] + if d then + local isSel = id == sel + table.insert(items, ui.row({ + key = "sw-" .. id, gap = 8, align = "center", padding = 8, radius = 6, + fill = isSel and "primary/0.12" or "surface/0.4", + onClick = function() sendCmd({ op = "select", device = id }) end, + }, { + ui.glyph({ name = glyphForType(d.type), size = 14, + color = d.isReachable and "primary" or "on_surface_variant" }), + ui.label({ text = aliasMap[id] or "Your-Phone", fontSize = 12, flexGrow = 1, color = "on_surface" }), + ui.label({ text = (type(d.batteryCharge) == "number" and d.batteryCharge >= 0) + and (tostring(d.batteryCharge) .. "%") or "", fontSize = 10, + color = "on_surface_variant" }), + })) + end + end + return ui.column({ gap = 4, padding = 8, radius = 8, fill = "surface/0.3" }, items) +end + +-- ── Main render ───────────────────────────────────────────────────────────── +function render() + local backend = noctalia.state.get("pc.backend") or { available = false } + local devices = noctalia.state.get("pc.devices") or {} + local order = noctalia.state.get("pc.order") or {} + local sel = noctalia.state.get("pc.selected") + if not sel or not devices[sel] then sel = order[1] end + + local body = {} + -- header (full width top) + table.insert(body, ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "device-mobile", size = 18, color = "primary" }), + ui.label({ text = t("panel.title"), fontSize = 16, fontWeight = "bold", + color = "on_surface", flexGrow = 1 }), + ui.button({ glyph = "refresh", variant = "ghost", controlSize = "sm", + onClick = function() sendCmd({ op = "refresh" }) end }), + ui.button({ glyph = "close", variant = "ghost", controlSize = "sm", + onClick = function() panel.close() end }), + })) + + if not backend.available then + table.insert(body, ui.column({ gap = 8, padding = 40, align = "center", flexGrow = 1 }, { + ui.glyph({ name = "phone-off", size = 40, color = "on_surface_variant" }), + ui.label({ text = t("status.no_backend"), color = "on_surface_variant" }), + })) + elseif not sel or not devices[sel] then + table.insert(body, ui.column({ gap = 8, padding = 40, align = "center", flexGrow = 1 }, { + ui.glyph({ name = "phone-off", size = 40, color = "on_surface_variant" }), + ui.label({ text = t("panel.empty"), color = "on_surface_variant" }), + })) + else + local d = devices[sel] + -- top: hero + info (full width) + table.insert(body, heroCard(d)) + table.insert(body, infoGrid(d)) + -- middle: two-column row (left content flex, right actions fixed) + table.insert(body, ui.row({ gap = 14, flexGrow = 1, align = "stretch", justify = "center" }, { + leftColumn(d), + rightColumn(d), + })) + -- bottom: device switcher (only if multiple devices) + local sw = switcher(devices, order, sel) + if sw then table.insert(body, sw) end + end + + panel.render(ui.column({ gap = 10, padding = 12 }, body)) +end + +function onOpen(_context) render() end +function onClose() end + +noctalia.state.watch("pc.devices", render) +noctalia.state.watch("pc.order", render) +noctalia.state.watch("pc.selected", render) +noctalia.state.watch("pc.backend", render) +noctalia.state.watch("pc.imageMap", render) +noctalia.state.watch("pc.trTable", render) +noctalia.state.watch("pc.aliasMap", render) diff --git a/phone-connect/plugin.toml b/phone-connect/plugin.toml new file mode 100644 index 0000000..c48cd34 --- /dev/null +++ b/phone-connect/plugin.toml @@ -0,0 +1,175 @@ +# Noctalia v5 plugin manifest for Phone Connect (KDE Connect backend only) +# Plugin system is beta; APIs may change before v5 stable. +# +# plugin_api = 16: targets the last released level of v5.0.0. We avoid 17/18 +# (Unreleased) so the plugin loads on stable v5.0.0. None of our code relies on +# 17+ features: onExit is unused, onConfigChanged is a baseline service hook. +# +# Status: KDE Connect only. Valent backend is intentionally out of scope for +# now, but the service entry (service.luau) is written as a swappable backend +# owner that publishes state to UI entries, so Valent can replace its internals +# later without UI changes. + +id = "icefish/phone-connect" +name = "Phone Connect" +version = "0.1.0" +plugin_api = 16 +author = "icefish" +license = "MIT" +deprecated = false +icon = "devices" +description = "Control paired phones via KDE Connect — battery, ring, ping, share, clipboard, pairing." +tags = ["bar", "panel", "service", "media"] +dependencies = ["kdeconnect", "sshfs"] + +# ── Plugin-level settings (shared across all entries) ─────────────────────── +# Mirrors the persisted keys of the original DankKDEConnect plugin where they +# make sense as typed settings. Per-device maps (image/type/recent-images-path) +# stay in pluginDataDir() JSON because they are dynamic, not declarative. + +[[setting]] +key = "state_update_interval" +type = "int" +label_key = "settings.state_update_interval.label" +description_key = "settings.state_update_interval.description" +default = 30 +min = 0 +max = 300 + +[[setting]] +key = "enable_charging_animation" +type = "bool" +label_key = "settings.enable_charging_animation.label" +description_key = "settings.enable_charging_animation.description" +default = true + +[[setting]] +key = "custom_image" +type = "file" +label_key = "settings.custom_image.label" +description_key = "settings.custom_image.description" +default = "" +extensions = [".png", ".jpg", ".jpeg", ".webp", ".svg"] + +[[setting]] +key = "device_alias" +type = "string" +label_key = "settings.device_alias.label" +description_key = "settings.device_alias.description" +default = "Your-Phone" + +[[setting]] +key = "language" +type = "select" +label_key = "settings.language.label" +description_key = "settings.language.description" +default = "en" +options = [ + { value = "en", label_key = "settings.language.en" }, + { value = "zh_hans", label_key = "settings.language.zh_hans" }, +] + +[[setting]] +key = "enable_clipboard_action" +type = "bool" +label_key = "settings.enable_clipboard_action.label" +description_key = "settings.enable_clipboard_action.description" +default = true + +[[setting]] +key = "show_ongoing_media" +type = "bool" +label_key = "settings.show_ongoing_media.label" +description_key = "settings.show_ongoing_media.description" +default = true + +[[setting]] +key = "show_device_placeholder" +type = "bool" +label_key = "settings.show_device_placeholder.label" +description_key = "settings.show_device_placeholder.description" +default = true + +[[setting]] +key = "scan_subdirectories" +type = "bool" +label_key = "settings.scan_subdirectories.label" +description_key = "settings.scan_subdirectories.description" +default = false + +[[setting]] +key = "max_recent_images" +type = "int" +label_key = "settings.max_recent_images.label" +description_key = "settings.max_recent_images.description" +default = 4 +min = 1 +max = 12 + +[[setting]] +key = "panel_placement" +type = "select" +label_key = "settings.panel_placement.label" +description_key = "settings.panel_placement.description" +default = "attached" +options = [ + { value = "attached", label_key = "settings.panel_placement.attached" }, + { value = "floating", label_key = "settings.panel_placement.floating" }, + { value = "widget", label_key = "settings.panel_placement.widget" }, +] + +# ── Entries ───────────────────────────────────────────────────────────────── +# Four entries: a background service that talks DBus, a bar widget, a control +# center tile, and a panel for the detailed device view. + +[[service]] +id = "kde" +entry = "service.luau" + +[[widget]] +id = "bar" +entry = "widget.luau" + +# Right-click opens the details panel via onRightClick in the script +# (noctalia.togglePanel). We deliberately do not bind [widget.actions] here: +# the panel-toggle action grammar for opening a *plugin* panel by id is not +# documented, so we drive it from the script instead. + +[[shortcut]] +id = "tile" +entry = "tile.luau" + +[[panel]] +id = "details" +entry = "panel.luau" +width = 500 +height = 600 +placement = "attached" +position = "auto" +open_near_click = true +keyboard_focus = "on_demand" + +# Floating variant: same script, opens detached (screen center). The widget/tile +# pick which panel id to toggle based on the "panel_placement" setting, so users +# choose attached vs floating without a runtime setPlacement API. +[[panel]] +id = "details_floating" +entry = "panel.luau" +width = 500 +height = 600 +placement = "floating" +position = "center" +keyboard_focus = "on_demand" + +# Floating-near-widget variant: floating surface that opens near the toggling +# bar widget (open_near_click), i.e. suspended just below the widget rather than +# anchored to the bar edge (attached) or screen-centered (floating). +[[panel]] +id = "details_widget" +entry = "panel.luau" +width = 500 +height = 600 +placement = "floating" +position = "auto" +open_near_click = true +keyboard_focus = "on_demand" diff --git a/phone-connect/service.luau b/phone-connect/service.luau new file mode 100644 index 0000000..0d45a42 --- /dev/null +++ b/phone-connect/service.luau @@ -0,0 +1,760 @@ +-- entries/service.luau +-- Phone Connect - KDE Connect backend service (the single owner of DBus logic). +-- +-- Architecture: Noctalia plugin scripts run in an isolated sandbox with NO +-- `require`/`dofile`/`loadfile`. So all KDE Connect interaction lives here and +-- is published to UI entries (widget/tile/panel) as plain data via +-- `noctalia.state`. A future Valent backend would replace this file's internals +-- only; the state contract below stays unchanged so UI entries need no edits. +-- + +-- Local translation: reads from pc.trTable (published by service) so users +-- can switch language at runtime, unlike noctalia.tr() which follows system locale. +local function t(key) + local table = noctalia.state.get("pc.trTable") or {} + return table[key] or key +end + +-- ── State contract (read by UI entries) ────────────────────────────────────── +-- noctalia.state "pc.backend" : { available=bool, name="KDE Connect"|"None", +-- announcedName="", selfId="" } +-- noctalia.state "pc.devices" : { [id] = { id, name, type, isReachable, +-- isPaired, pairState, verificationKey, +-- supportedPlugins={}, batteryCharge, +-- batteryCharging, networkType, +-- networkStrength } } +-- noctalia.state "pc.order" : { id, ... } device id display order +-- noctalia.state "pc.selected" : string currently selected device id +-- noctalia.state "pc.cmd" : { op=string, device=string, [args...] } +-- written by UI entries; service executes +-- and clears. ops: ring|ping|clipboard| +-- share_text|share_url|share_file|pair| +-- accept|reject|unpair|browse|select|refresh +-- noctalia.state "pc.event" : { type=string, ... } transient UI events +-- (pairing request, file received) the panel +-- may surface; service sets, panel consumes. +-- +-- ── KDE Connect DBus surface (verified via gdbus introspect) ───────────────── +-- service : org.kde.kdeconnect +-- daemon : /modules/kdeconnect iface org.kde.kdeconnect.daemon +-- methods: devices(b,b)->as, selfId()->s, announcedName()->s +-- signals: deviceAdded(s), deviceRemoved(s), +-- deviceVisibilityChanged(s,b), deviceListChanged(), +-- pairingRequestsChanged() +-- device : /modules/kdeconnect/devices/ iface org.kde.kdeconnect.device +-- props: type,name,isReachable,isPaired,pairState,verificationKey, +-- supportedPlugins,statusIconName +-- methods: requestPairing,acceptPairing,cancelPairing,unpair +-- signals: reachableChanged(b), pairStateChanged(i), +-- nameChanged(s), typeChanged(s), statusIconNameChanged +-- battery : /battery props: charge(i),isCharging(b); sig: refreshed(b,i) +-- conn : /connectivity_report props: cellularNetworkType(s), +-- cellularNetworkStrength(i); sig: refreshed(s,i) +-- share : /share methods: shareUrl(s),shareText(s); sig: shareReceived(s) +-- ping : /ping methods: sendPing(), sendPing(s) +-- sftp : /sftp methods: startBrowsing()->b, mount(), mountAndWait()->b, +-- mountPoint()->s, isMounted()->b + +-- ── Shell helpers (duplicated per the sandbox constraint; keep tiny) ───────── +local function shellQuote(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" +end + +local SVC = "org.kde.kdeconnect" +local DAEMON_PATH = "/modules/kdeconnect" +local DAEMON_IFACE = "org.kde.kdeconnect.daemon" +local DEV_IFACE = "org.kde.kdeconnect.device" +local PROPS_IFACE = "org.freedesktop.DBus.Properties" +local BATTERY_IFACE = "org.kde.kdeconnect.device.battery" +local CONN_IFACE = "org.kde.kdeconnect.device.connectivity_report" +local MPRIS_IFACE = "org.kde.kdeconnect.device.mprisremote" + +-- Build a gdbus call that returns one property as a variant. +local function gdbusGetProp(devPath, iface, prop) + return table.concat({ + "gdbus call --session", + "--dest", shellQuote(SVC), + "--object-path", shellQuote(devPath), + "--method", shellQuote(PROPS_IFACE .. ".Get"), + shellQuote(iface), shellQuote(prop), + }, " ") +end + +-- Parse a single gdbus return value. gdbus prints results as a tuple: +-- variant string : (<'24122RKC7C'>,) +-- variant bool : (,) +-- variant int : (<-1>,) +-- bare bool : (true,) (non-variant, e.g. NameHasOwner) +-- Validated against real kdeconnect gdbus output. +local function parseGdbusValue(raw) + if raw == nil then return nil end + raw = raw:gsub("^%s+", ""):gsub("%s+$", "") + -- variant form: (,) + local inner = raw:match("^%(<(.+)>%,?%)?$") + if inner then + if inner == "true" then return true end + if inner == "false" then return false end + local n = tonumber(inner) + if n then return n end + local s = inner:match("^'(.*)'$") or inner:match('^"(.*)"$') + if s then return s end + return inner + end + -- bare form: (true,) (false,) (-1,) -> strip tuple parens and trailing comma + local bare = raw:match("^%((.-)%,?%)$") + if bare == nil then bare = raw end + bare = bare:gsub("%s+$", ""):gsub(",%s*$", "") + if bare == "true" then return true end + if bare == "false" then return false end + local n = tonumber(bare) + if n then return n end + local s = bare:match("^'(.*)'$") or bare:match('^"(.*)"$') + if s then return s end + return bare +end + +-- Parse gdbus array-of-strings tuple like: (['id1', 'id2'],) +local function parseGdbusStringArray(raw) + if raw == nil then return {} end + local arr = raw:match("%[(.-)%]") + if arr == nil then return {} end + local out = {} + for item in arr:gmatch("'([^']*)'") do + table.insert(out, item) + end + return out +end + +-- ── Device data model ──────────────────────────────────────────────────────── +-- In-memory device table keyed by id (mirrors original PhoneConnectService.devices). +local devices= {} +local deviceOrder= {} + +local function snapshotDevices() + local snap = {} + for _, id in ipairs(deviceOrder) do + local d = devices[id] + if d then snap[id] = d end + end + return snap +end + +local function publishDevices() + noctalia.state.set("pc.devices", snapshotDevices()) + noctalia.state.set("pc.order", deviceOrder) +end + +-- ── Persistent state (selected device) ────────────────────────────────────── +-- noctalia.state is in-memory only and cleared when the plugin stops, so the +-- last selected device is persisted to pluginDataDir()/state.json. Per-device +-- image/type/recent-images maps will live here too once their UI lands. +local DATA_FILE -- resolved lazily; pluginDataDir() may be nil very early + +local function dataPath() + if DATA_FILE then return DATA_FILE end + local dir = noctalia.pluginDataDir() + if not dir then return nil end + DATA_FILE = dir .. "/state.json" + return DATA_FILE +end + +local function loadData() + local p = dataPath() + if not p then return {} end + local raw = noctalia.readFile(p) + if not raw then return {} end + local ok, data = pcall(noctalia.json.decode, raw) + if ok and type(data) == "table" then return data end + return {} +end + +local function saveData(data) + local p = dataPath() + if not p then return end + local ok, encoded = pcall(noctalia.json.encode, data) + if ok and encoded then noctalia.writeFile(p, encoded) end +end + + local function persistSelected(dev) + local data = loadData() + data.selected = dev + saveData(data) + end + + local function loadImageMap() + return loadData().imageMap or {} + end + + local function persistImageMap(map) + local data = loadData() + data.imageMap = map + saveData(data) + end + + local function loadAliasMap() + return loadData().aliasMap or {} + end + + local function persistAliasMap(map) + local data = loadData() + data.aliasMap = map + saveData(data) + end + + -- restore last selection and image map at load + do + local saved = loadData() + if saved.selected and type(saved.selected) == "string" and saved.selected ~= "" then + noctalia.state.set("pc.selected", saved.selected) + end + noctalia.state.set("pc.imageMap", saved.imageMap or {}) + noctalia.state.set("pc.aliasMap", saved.aliasMap or {}) + end + + -- ── Translation loader ────────────────────────────────────────────────── + -- noctalia.tr() follows system locale and can't be switched at runtime, + -- so we load both translations ourselves and publish the active one + -- to state. Each UI entry reads pc.trTable for a local t(key) wrapper. + local function flattenTable(t, prefix) + local out = {} + for k, v in pairs(t) do + local full = prefix and (prefix .. "." .. k) or k + if type(v) == "table" then + local sub = flattenTable(v, full) + for sk, sv in pairs(sub) do out[sk] = sv end + else + out[full] = v + end + end + return out + end + + local function loadTranslations(lang) + local dir = noctalia.pluginDir() + if not dir then return {} end + local raw = noctalia.readFile(dir .. "/translations/" .. lang .. ".json") + if not raw then return {} end + local ok, data = pcall(noctalia.json.decode, raw) + if ok and type(data) == "table" then return flattenTable(data) end + return {} + end + + local function publishTranslations(lang) + local table = loadTranslations(lang or "en") + noctalia.state.set("pc.trTable", table) + noctalia.state.set("pc.lang", lang) + end + + -- initial load + publishTranslations(noctalia.getConfig("language") or "en") + +-- ── Backend availability ───────────────────────────────────────────────────── +local function detectBackend() + local hasGdbus = noctalia.commandExists("gdbus") + local hasCli = noctalia.commandExists("kdeconnect-cli") + local available = noctalia.commandExists("kdeconnectd") or hasGdbus or hasCli + -- A more precise check: is the DBus name actually owned? Use gdbus if present. + if hasGdbus then + noctalia.runAsync( + "gdbus call --session --dest org.freedesktop.DBus " + .. "--object-path /org/freedesktop/DBus " + .. "--method org.freedesktop.DBus.NameHasOwner " + .. shellQuote(SVC), + function(res) + local owned = false + if res and not res.error then + owned = parseGdbusValue(res.stdout or "") == true + end + noctalia.state.set("pc.backend", { + available = owned, + name = owned and "KDE Connect" or "None", + announcedName = "", + selfId = "", + hasGdbus = hasGdbus, + hasCli = hasCli, + }) + end + ) + else + noctalia.state.set("pc.backend", { + available = available, + name = available and "KDE Connect" or "None", + announcedName = "", + selfId = "", + hasGdbus = false, + hasCli = hasCli, + }) + end +end + +-- ── Fetch device properties via gdbus ──────────────────────────────────────── +-- Fetches the device interface properties and merges into the in-memory record. +local function fetchDeviceProps(id, cb) + local devPath = DAEMON_PATH .. "/devices/" .. id + -- GetAll would be ideal but gdbus call needs the method signature; use + -- individual Get calls for the fields we need (simple and robust). + local fields = { + { "name" }, { "type" }, { "isReachable" }, { "isPaired" }, + { "pairState" }, { "verificationKey" }, + } + -- We issue them sequentially via a small recursive helper to keep ordering + -- simple. (Concurrent runAsync would race on the shared `devices` table.) + local i = 1 + local function next() + if i > #fields then + publishDevices() + if cb then cb() end + return + end + local prop = fields[i][1] + i = i + 1 + noctalia.runAsync(gdbusGetProp(devPath, DEV_IFACE, prop), function(res) + local d = devices[id] or { id = id } + if res and not res.error then + d[prop] = parseGdbusValue(res.stdout or "") + end + devices[id] = d + next() + end) + end + next() +end + +-- Fetch battery + connectivity sub-interface properties (only if paired+reachable). +local function fetchDeviceExtras(id) + local d = devices[id] + if not d then return end + if not (d.isPaired and d.isReachable) then return end + local devPath = DAEMON_PATH .. "/devices/" .. id + -- battery.charge / battery.isCharging + noctalia.runAsync(gdbusGetProp(devPath .. "/battery", + BATTERY_IFACE, "charge"), function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2.batteryCharge = parseGdbusValue(res.stdout or "") + devices[id] = d2 + publishDevices() + end + end + end) + noctalia.runAsync(gdbusGetProp(devPath .. "/battery", + BATTERY_IFACE, "isCharging"), function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2.batteryCharging = parseGdbusValue(res.stdout or "") + devices[id] = d2 + publishDevices() + end + end + end) + -- connectivity + noctalia.runAsync(gdbusGetProp(devPath .. "/connectivity_report", + CONN_IFACE, "cellularNetworkType"), + function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2.networkType = parseGdbusValue(res.stdout or "") + devices[id] = d2 + publishDevices() + end + end + end) + noctalia.runAsync(gdbusGetProp(devPath .. "/connectivity_report", + CONN_IFACE, "cellularNetworkStrength"), + function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2.networkStrength = parseGdbusValue(res.stdout or "") + devices[id] = d2 + publishDevices() + -- if network still default, nudge the phone to report + -- (but not more than once per 120s) + if (type(d2.networkType) ~= "string" or d2.networkType == "") + and d2.networkStrength == -1 then + local now = os.time() + local last = d2._netRefreshAt or 0 + if now - last > 120 then + d2._netRefreshAt = now + devices[id] = d2 + runCli("--refresh -d " .. shellQuote(id)) + end + end + end + end + end) + -- mprisremote: media fields fetched sequentially (parallel had callback races). + -- We publish once at the end to avoid flicker. + local mprisFields = { "isPlaying", "title", "artist", "album", "volume", + "localAlbumArtUrl", "length", "position", "canSeek" } + local mi = 1 + local function fetchMprisNext() + if mi > #mprisFields then + local d2 = devices[id] + if d2 and d2.medialocalAlbumArtUrl and d2.medialocalAlbumArtUrl ~= "" then + local art = d2.medialocalAlbumArtUrl + if art:sub(1, 7) == "file://" then art = art:sub(8) end + d2.mediaArtPath = art + devices[id] = d2 + end + publishDevices() + return + end + local prop = mprisFields[mi] + mi = mi + 1 + noctalia.runAsync(gdbusGetProp(devPath .. "/mprisremote", MPRIS_IFACE, prop), + function(res) + if res and not res.error then + local d2 = devices[id] + if d2 then + d2["media" .. prop] = parseGdbusValue(res.stdout or "") + devices[id] = d2 + end + end + fetchMprisNext() + end) + end + fetchMprisNext() +end + +-- ── Full device list refresh ───────────────────────────────────────────────── +local refreshing = false +local function refreshDevices() + if refreshing then return end + refreshing = true + noctalia.runAsync( + "gdbus call --session --dest " .. SVC + .. " --object-path " .. DAEMON_PATH + .. " --method " .. DAEMON_IFACE .. ".devices false false", + function(res) + refreshing = false + if not res or res.error then + noctalia.log("[phone-connect] devices() failed: " + .. (res and res.error or "nil")) + return + end + local ids = parseGdbusStringArray(res.stdout or "") + -- remove gone devices + local seen = {} + for _, id in ipairs(ids) do seen[id] = true end + for i = #deviceOrder, 1, -1 do + if not seen[deviceOrder[i]] then + devices[deviceOrder[i]] = nil + table.remove(deviceOrder, i) + end + end + -- add new + for _, id in ipairs(ids) do + if not devices[id] then + table.insert(deviceOrder, id) + devices[id] = { id = id } + end + end + -- fetch props for each (sequential via a chain) + local idx = 1 + local function fetchNext() + if idx > #deviceOrder then + publishDevices() + -- then extras for reachable paired devices + for _, id2 in ipairs(deviceOrder) do + fetchDeviceExtras(id2) + end + return + end + local id = deviceOrder[idx] + idx = idx + 1 + fetchDeviceProps(id, fetchNext) + end + fetchNext() + end) +end + +-- ── Command execution (from UI entries via pc.cmd) ────────────────────────── +-- We use polling for state (verified reliable) instead of DBus signal +-- monitoring: empirically, dbus-monitor captured ZERO kdeconnect-path signals +-- even when triggering refresh/ping/pairing cycles, because kdeconnectd emits +-- most state changes from the phone side on device-specific paths and they are +-- sparse on a stable paired device. So: periodic update() refreshes state, and +-- state-mutating commands trigger an immediate refreshDevices() on completion +-- for responsive UI without waiting for the next poll. + +local function runCli(args, cb) + noctalia.runAsync("kdeconnect-cli " .. args, function(res) + if res and res.error then + noctalia.notifyError(t("error.no_backend"), res.error) + end + if cb then cb(res) end + end) +end + +local function gdbusDeviceMethod(id, method, cb) + local path = DAEMON_PATH .. "/devices/" .. id + noctalia.runAsync( + "gdbus call --session --dest " .. SVC + .. " --object-path " .. path + .. " --method " .. DEV_IFACE .. "." .. method, + function(res) + if res and res.error then + noctalia.log("[phone-connect] " .. method .. " failed: " .. res.error) + end + if cb then cb(res) end + end) +end + +-- Call a method on a device sub-interface (e.g. sftp, mprisremote, sms). +-- sub is the path suffix + interface suffix, e.g. "sftp" -> path /devices//sftp, +-- iface org.kde.kdeconnect.device.sftp. extraArgs is a shell-quoted arg string. +local function gdbusSubMethod(id, sub, method, extraArgs, cb) + local path = DAEMON_PATH .. "/devices/" .. id .. "/" .. sub + local iface = "org.kde.kdeconnect.device." .. sub + local cmd = "gdbus call --session --dest " .. SVC + .. " --object-path " .. shellQuote(path) + .. " --method " .. shellQuote(iface .. "." .. method) + if extraArgs and extraArgs ~= "" then cmd = cmd .. " " .. extraArgs end + noctalia.runAsync(cmd, function(res) + if res and res.error then + noctalia.log("[phone-connect] " .. sub .. "." .. method .. " failed: " .. res.error) + end + if cb then cb(res) end + end) +end + +-- Call a sub-interface method that returns a single value; parse the result. +local function gdbusSubMethodValue(id, sub, method, cb) + gdbusSubMethod(id, sub, method, "", function(res) + if not res or res.error then cb(nil) return end + cb(parseGdbusValue(res.stdout or "")) + end) +end + +local function handleCommand(cmd) + if type(cmd) ~= "table" then return end + local op = cmd.op + local dev = cmd.device or "" + -- helper: run a mutating op, then refresh so the UI updates immediately + local function mutating(fn) + fn(function() refreshDevices() end) + end + if op == "refresh" then + refreshDevices() + elseif op == "select" then + noctalia.state.set("pc.selected", dev) + persistSelected(dev) + elseif op == "ring" then + runCli("--ring -d " .. shellQuote(dev)) + elseif op == "ping" then + local msg = cmd.message or "" + if msg ~= "" then + runCli("--ping-msg " .. shellQuote(msg) .. " -d " .. shellQuote(dev)) + else + runCli("--ping -d " .. shellQuote(dev)) + end + elseif op == "clipboard" then + runCli("--send-clipboard -d " .. shellQuote(dev)) + elseif op == "share_text" then + runCli("--share-text " .. shellQuote(cmd.text or "") .. " -d " .. shellQuote(dev)) + elseif op == "share_url" then + runCli("--share " .. shellQuote(cmd.url or "") .. " -d " .. shellQuote(dev)) + elseif op == "share_file" then + runCli("--share " .. shellQuote(cmd.path or "") .. " -d " .. shellQuote(dev)) + elseif op == "set_image" then + local map = noctalia.state.get("pc.imageMap") or {} + local path = cmd.path or "" + if path == "" then map[dev] = nil else map[dev] = path end + noctalia.state.set("pc.imageMap", map) + persistImageMap(map) + elseif op == "pair" then + mutating(function(cb) gdbusDeviceMethod(dev, "requestPairing", cb) end) + elseif op == "accept" then + mutating(function(cb) gdbusDeviceMethod(dev, "acceptPairing", cb) end) + elseif op == "reject" then + mutating(function(cb) gdbusDeviceMethod(dev, "cancelPairing", cb) end) + elseif op == "unpair" then + mutating(function(cb) gdbusDeviceMethod(dev, "unpair", cb) end) + elseif op == "browse" then + -- SFTP: ensure mounted, then open the phone's storage directory. + local function tryOpen() + gdbusSubMethodValue(dev, "sftp", "mountPoint", function(mp) + if not mp or mp == "" then + noctalia.notifyError(t("error.browse_failed"), "no mount point") + return + end + -- getDirectories returns a{sv}, we need to parse it differently. + -- Use a raw gdbus call and extract the first directory path. + local path = DAEMON_PATH .. "/devices/" .. dev .. "/sftp" + local cmd = "gdbus call --session --dest " .. SVC + .. " --object-path " .. shellQuote(path) + .. " --method org.kde.kdeconnect.device.sftp.getDirectories" + noctalia.runAsync(cmd, function(res) + local dirPath = mp + if res and not res.error then + -- output like: ({'/run/.../storage/emulated/0': <'Internal'>,},) + -- extract the first path in single quotes + local first = (res.stdout or ""):match("'([^']+)'") + if first then dirPath = first end + end + noctalia.runInTerminal("xdg-open " .. shellQuote(dirPath)) + noctalia.notify(t("notify.opening_browser"), dirPath) + end) + end) + end + gdbusSubMethodValue(dev, "sftp", "isMounted", function(mounted) + if mounted then + tryOpen() + else + gdbusSubMethodValue(dev, "sftp", "mountAndWait", function(ok) + if ok then + tryOpen() + else + gdbusSubMethodValue(dev, "sftp", "getMountError", function(errMsg) + noctalia.notifyError(t("error.browse_failed"), + errMsg or "mount failed (sshfs installed?)") + end) + end + end) + end + end) + elseif op == "sms_send" then + -- Send an SMS via kdeconnect-cli (handles address/message/attachment). + local args = "--send-sms " .. shellQuote(cmd.text or "") + .. " --destination " .. shellQuote(cmd.destination or "") + .. " -d " .. shellQuote(dev) + if cmd.attachment and cmd.attachment ~= "" then + args = args .. " --attachment " .. shellQuote(cmd.attachment) + end + runCli(args, function(res) + if res and not res.error then + noctalia.notify(t("notify.sms_sent"), cmd.destination or "") + end + end) + elseif op == "launch_sms_app" then + gdbusSubMethod(dev, "sms", "launchApp", "", nil) + elseif op == "media" then + -- MPRIS control. action: Play|Pause|PlayPause|Next|Previous|Stop + local action = cmd.action or "PlayPause" + gdbusSubMethod(dev, "mprisremote", "sendAction", + shellQuote(action), function() + -- delay 600ms for phone to update MPRIS state + noctalia.runAsync("sleep 0.6", function() + refreshDevices() + end) + end) + elseif op == "media_seek" then + -- set position property directly (seek method is unreliable) + local offset = tonumber(cmd.offset) or 0 + local path = DAEMON_PATH .. "/devices/" .. dev .. "/mprisremote" + noctalia.runAsync( + "gdbus call --session --dest " .. SVC + .. " --object-path " .. shellQuote(path) + .. " --method " .. shellQuote(PROPS_IFACE .. ".Set") + .. " " .. MPRIS_IFACE .. " position " + .. shellQuote(""), + function() + noctalia.runAsync("sleep 0.3", function() refreshDevices() end) + end) + elseif op == "media_set_volume" then + -- volume is a readwrite property; use gdbus Set. + local vol = tonumber(cmd.volume) or 0 + local path = DAEMON_PATH .. "/devices/" .. dev .. "/mprisremote" + noctalia.runAsync( + "gdbus call --session --dest " .. SVC + .. " --object-path " .. shellQuote(path) + .. " --method " .. shellQuote(PROPS_IFACE .. ".Set") + .. " " .. shellQuote(MPRIS_IFACE) .. " volume " + .. "", + function() refreshDevices() end) + else + noctalia.log("[phone-connect] unknown cmd op: " .. tostring(op)) + end +end + +-- ── Signal monitoring ──────────────────────────────────────────────────────── +-- Deliberately NOT implemented: see note above the command section. Polling +-- (update()) + immediate refresh after mutating commands covers state updates +-- reliably without the fragility of parsing dbus-monitor's multi-line variant +-- output. Revisit only if a concrete need for push notifications (e.g. incoming +-- shareReceived) arises; even then, prefer `gdbus monitor` filtered to one +-- member over a generic parser. + +-- ── Lifecycle ──────────────────────────────────────────────────────────────── +local function intervalMs() + local secs = tonumber(noctalia.getConfig("state_update_interval")) or 30 + if secs <= 0 then return 60000 end + -- if media is playing, poll faster for live position updates + for _, d in pairs(devices) do + if d.mediaisPlaying == true then return 1000 end + end + return secs * 1000 +end + +function update() + noctalia.setUpdateInterval(intervalMs()) + detectBackend() + refreshDevices() +end + +function onConfigChanged() + noctalia.setUpdateInterval(intervalMs()) + -- apply custom_image to selected device + local img = noctalia.getConfig("custom_image") + if img ~= nil then + local sel = noctalia.state.get("pc.selected") + if sel and sel ~= "" then + local map = noctalia.state.get("pc.imageMap") or {} + if img == "" then map[sel] = nil else map[sel] = img end + noctalia.state.set("pc.imageMap", map) + persistImageMap(map) + end + end + -- apply device_alias to selected device + local alias = noctalia.getConfig("device_alias") + if alias ~= nil then + local sel = noctalia.state.get("pc.selected") + if sel and sel ~= "" then + local amap = noctalia.state.get("pc.aliasMap") or {} + if alias == "Your-Phone" then amap[sel] = nil else amap[sel] = alias end + noctalia.state.set("pc.aliasMap", amap) + persistAliasMap(amap) + end + end + -- reload translations when language changes + local lang = noctalia.getConfig("language") or "en" + if lang ~= noctalia.state.get("pc.lang") then + publishTranslations(lang) + end +end + +-- IPC hook for external driving / testing: `noctalia msg plugin [payload]` +-- event "refresh" -> full device refresh +-- event "cmd" payload=json -> execute a command table ({"op":"ring","device":"id"}) +function onIpc(event, payload) + if event == "refresh" then + refreshDevices() + elseif event == "cmd" and payload then + local ok, cmd = pcall(noctalia.json.decode, payload) + if ok and type(cmd) == "table" then + handleCommand(cmd) + else + noctalia.log("[phone-connect] onIpc cmd: invalid json payload") + end + end +end + +-- Top-level init runs once at load. +detectBackend() +refreshDevices() +-- Command channel: UI entries write { op, device, seq, ... } to "pc.cmd". +-- seq is a high-resolution timestamp so it never resets on hot-reload (unlike +-- a counter which would cause commands to be silently dropped after UI reload). +local lastCmdSeq = 0 +noctalia.state.watch("pc.cmd", function(cmd) + if type(cmd) ~= "table" then return end + local seq = tonumber(cmd.seq) or 0 + if seq <= lastCmdSeq then return end + lastCmdSeq = seq + cmd.seq = nil + handleCommand(cmd) +end) diff --git a/phone-connect/thumbnail.webp b/phone-connect/thumbnail.webp new file mode 100644 index 0000000..e13c57c Binary files /dev/null and b/phone-connect/thumbnail.webp differ diff --git a/phone-connect/tile.luau b/phone-connect/tile.luau new file mode 100644 index 0000000..4f6f359 --- /dev/null +++ b/phone-connect/tile.luau @@ -0,0 +1,89 @@ +-- tile.luau +-- Phone Connect - control-center tile (thin UI entry). +-- +-- Shows a compact status: connected count or selected device battery. Click +-- opens the details panel. Reads state from the service entry; no DBus here. + +-- Pick which panel id to toggle based on the user's panel_placement setting. + +-- Local translation: reads from pc.trTable (published by service) so users +-- can switch language at runtime, unlike noctalia.tr() which follows system locale. +local function t(key) + local table = noctalia.state.get("pc.trTable") or {} + return table[key] or key +end + +local function panelId() + local mode = noctalia.getConfig("panel_placement") + if mode == "floating" then + return "icefish/phone-connect:details_floating" + elseif mode == "widget" then + return "icefish/phone-connect:details_widget" + end + return "icefish/phone-connect:details" +end + +local function selectedDevice() + local devices = noctalia.state.get("pc.devices") or {} + local order = noctalia.state.get("pc.order") or {} + local sel = noctalia.state.get("pc.selected") + local id = sel + if not id or not devices[id] then id = order[1] end + if id then return devices[id] end + return nil +end + +local function countReachable() + local devices = noctalia.state.get("pc.devices") or {} + local order = noctalia.state.get("pc.order") or {} + local n = 0 + for _, id in ipairs(order) do + local d = devices[id] + if d and d.isReachable == true then n = n + 1 end + end + return n +end + +local function render() + local backend = noctalia.state.get("pc.backend") or { available = false } + if not backend.available then + shortcut.setIcon("phone-off") + shortcut.setLabel(t("status.no_backend")) + shortcut.setActive(false) + shortcut.setEnabled(false) + return + end + + local n = countReachable() + local d = selectedDevice() + shortcut.setEnabled(true) + + if d and d.isReachable and type(d.batteryCharge) == "number" and d.batteryCharge >= 0 then + -- show selected device battery when reachable + shortcut.setIcon("device-mobile") + shortcut.setLabel(tostring(d.batteryCharge) .. "%") + shortcut.setActive(true) + else + shortcut.setIcon("device-mobile") + shortcut.setLabel(n > 0 and (tostring(n) .. " " .. t("status.connected")) + or t("status.no_devices")) + shortcut.setActive(n > 0) + end +end + +function update() + noctalia.setUpdateInterval(3000) + render() +end + +function onClick() + noctalia.togglePanel(panelId()) +end + +noctalia.state.watch("pc.devices", render) +noctalia.state.watch("pc.order", render) +noctalia.state.watch("pc.selected", render) +noctalia.state.watch("pc.backend", render) +noctalia.state.watch("pc.trTable", render) + +render() diff --git a/phone-connect/translations/en.json b/phone-connect/translations/en.json new file mode 100644 index 0000000..ebbe9f7 --- /dev/null +++ b/phone-connect/translations/en.json @@ -0,0 +1,118 @@ +{ + "settings": { + "state_update_interval": { + "label": "State Update Interval", + "description": "Seconds between automatic device state refreshes. 0 disables auto-refresh." + }, + "enable_charging_animation": { + "label": "Show Charging Fill", + "description": "Highlight the bar widget when the device is charging." + }, + "custom_image": { + "label": "Device Image", + "description": "Set a custom image for the selected device. Leave empty for default icon." + }, + "device_alias": { + "label": "Device Alias", + "description": "Custom display name for the selected device." + }, + "language": { + "label": "Language", + "description": "Display language", + "en": "English", + "zh_hans": "简体中文" + }, + "enable_clipboard_action": { + "label": "Show Clipboard Action", + "description": "Show a quick action to send the clipboard to the device." + }, + "show_ongoing_media": { + "label": "Show Ongoing Media", + "description": "Show media currently playing on the phone." + }, + "show_device_placeholder": { + "label": "Show Device Placeholder", + "description": "Show the device graphic in the details panel." + }, + "scan_subdirectories": { + "label": "Scan Subdirectories", + "description": "Recursively scan subdirectories of the recent images path." + }, + "max_recent_images": { + "label": "Max Recent Images", + "description": "Number of recent images to display." + }, + "panel_placement": { + "label": "Panel Placement", + "description": "How the details panel opens: attached to the bar, or floating.", + "attached": "Attached to bar", + "floating": "Floating (center)", + "widget": "Below widget (floating)" + } + }, + "status": { + "unavailable": "Unavailable", + "no_devices": "No devices", + "offline": "Offline", + "connected": "Connected", + "not_paired": "Not paired", + "no_backend": "KDE Connect not running" + }, + "action": { + "ring": "Ring", + "ping": "Ping", + "clipboard": "Clipboard", + "share": "Share", + "browse": "Browse", + "sms": "SMS", + "pair": "Pair", + "accept": "Accept", + "reject": "Reject", + "unpair": "Unpair", + "refresh": "Refresh", + "switch": "Switch device" + }, + "notify": { + "ringing": "Ringing {name}...", + "ping_sent": "Ping sent to {name}", + "clipboard_sent": "Clipboard sent", + "pairing_sent": "Pairing request sent", + "paired": "Device paired", + "unpaired": "Device unpaired", + "opening_browser": "Opening file browser...", + "sms_sent": "SMS sent" + }, + "error": { + "ring_failed": "Failed to ring device", + "ping_failed": "Failed to send ping", + "clipboard_failed": "Failed to send clipboard", + "share_failed": "Failed to share", + "browse_failed": "Failed to browse device", + "pairing_failed": "Pairing failed", + "accept_failed": "Failed to accept pairing", + "reject_failed": "Failed to reject pairing", + "unpair_failed": "Unpair failed", + "no_backend": "No backend" + }, + "panel": { + "title": "Phone Connect", + "empty": "No devices found. Pair a device in KDE Connect.", + "devices_connected": "{connected} connected • {paired} paired", + "recent_images": "Recent Images", + "no_recent_images": "No recent images found", + "now_playing": "Now Playing", + "no_media": "No media playing", + "image_path": "Custom Image", + "sms": "Send SMS", + "sms_dest": "Phone number", + "sms_msg": "Message", + "sms_send": "Send", + "battery": "Battery", + "network": "Network", + "pairing": "Pairing", + "type": "Type", + "paired": "Paired", + "unpaired": "Unpaired", + "unknown": "unknown" + } +} \ No newline at end of file diff --git a/phone-connect/widget.luau b/phone-connect/widget.luau new file mode 100644 index 0000000..bbabf92 --- /dev/null +++ b/phone-connect/widget.luau @@ -0,0 +1,133 @@ +-- widget.luau +-- Phone Connect - bar widget (thin UI entry). +-- +-- Renders a bar pill reflecting the selected device: device glyph + battery +-- percent, colored by state (charging -> primary, low -> error, offline -> +-- dimmed). Left-click opens the details panel; right-click opens settings. +-- Reads state from the service entry; no DBus logic here. + +-- Local translation: reads from pc.trTable (published by service) so users +-- can switch language at runtime, unlike noctalia.tr() which follows system locale. +local function t(key) + local table = noctalia.state.get("pc.trTable") or {} + return table[key] or key +end + +local function panelId() + local mode = noctalia.getConfig("panel_placement") + if mode == "floating" then + return "icefish/phone-connect:details_floating" + elseif mode == "widget" then + return "icefish/phone-connect:details_widget" + end + return "icefish/phone-connect:details" +end + +local function glyphFor(d) + if not d or not d.isReachable then return "phone-off" end + local t = d.type + if t == "tablet" then return "device-tablet" end + if t == "laptop" then return "device-laptop" end + if t == "desktop" or t == "computer" then return "device-desktop" end + if t == "tv" then return "device-tv" end + return "device-mobile" +end + +local function batteryGlyph(charge, charging) + if charging then return "battery-charging" end + if type(charge) ~= "number" then return "battery" end + if charge >= 90 then return "battery" end + if charge >= 60 then return "battery-3" end + if charge >= 30 then return "battery-2" end + if charge > 0 then return "battery-1" end + return "battery-off" +end + +local function selectedDevice() + local devices = noctalia.state.get("pc.devices") or {} + local order = noctalia.state.get("pc.order") or {} + local sel = noctalia.state.get("pc.selected") + local id = sel + if not id or not devices[id] then id = order[1] end + if id then return devices[id] end + return nil +end + +local function render() + local backend = noctalia.state.get("pc.backend") or { available = false } + local d = selectedDevice() + local enableCharging = noctalia.getConfig("enable_charging_animation") + + local children + if not backend.available then + -- no backend: dim phone-off glyph + localized status + children = { + ui.glyph({ name = "phone-off", size = 14, color = "on_surface/0.4" }), + ui.label({ text = t("status.no_backend"), fontSize = 10, + color = "on_surface/0.5" }), + } + elseif not d then + children = { + ui.glyph({ name = "phone-off", size = 14, color = "on_surface/0.4" }), + ui.label({ text = t("status.no_devices"), fontSize = 10, + color = "on_surface/0.5" }), + } + else + local reachable = d.isReachable == true + local charge = d.batteryCharge + local charging = d.batteryCharging == true + local hasCharge = type(charge) == "number" and charge >= 0 + + -- device glyph: primary when charging, dim when offline, normal otherwise + local glyphColor = "on_surface" + if reachable and charging and enableCharging then + glyphColor = "primary" + elseif not reachable then + glyphColor = "on_surface/0.4" + end + + local kids = { ui.glyph({ name = glyphFor(d), size = 14, color = glyphColor }) } + + if reachable and hasCharge then + -- battery glyph + percent, colored by level + local batColor = "on_surface" + if charge <= 20 then batColor = "error" + elseif charging then batColor = "primary" end + table.insert(kids, ui.glyph({ name = batteryGlyph(charge, charging), + size = 12, color = batColor })) + table.insert(kids, ui.label({ + text = tostring(charge) .. "%", fontSize = 11, + fontWeight = "medium", color = batColor, + })) + elseif not reachable and d.isPaired then + -- offline but paired: show a small dimmed "offline" tag + table.insert(kids, ui.label({ text = t("status.offline"), + fontSize = 10, color = "on_surface/0.5" })) + end + children = kids + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 5, align = "center" }, children)) +end + +function update() + noctalia.setUpdateInterval(2000) + render() +end + +function onClick() + noctalia.togglePanel(panelId()) +end + +function onRightClick() + noctalia.openSettings() +end + +noctalia.state.watch("pc.devices", render) +noctalia.state.watch("pc.order", render) +noctalia.state.watch("pc.selected", render) +noctalia.state.watch("pc.backend", render) +noctalia.state.watch("pc.trTable", render) + +render()