add icefish/phone-connect v0.1.0 (#155)

* add icefish/phone-connect v0.1.0

* update phone-connect README with screenshot

* fix phone-connect: add thumbnail.webp and missing en.json keys

* fix phone-connect: remove unused assets and screenshot.png

* fix phone-connect: CI compliance fixes

* fix phone-connect: command seq and MPRIS performance

* fix phone-connect: MPRIS sequential + media action delay

* fix phone-connect: seek format + live position polling

* fix phone-connect: use Set position for media seek

* fix phone-connect: shell-quote seek variant

* fix phone-connect: seek position tracking

* fix phone-connect: final compliance — remove LICENSE, update thumbnail, fix tags
This commit is contained in:
CSY2569
2026-07-31 08:17:12 -04:00
committed by GitHub
parent ee84a9b11e
commit c3f96b59e7
8 changed files with 1728 additions and 0 deletions
+75
View File
@@ -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":"<id>"}'
```
## 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
+378
View File
@@ -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)
+175
View File
@@ -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"
+760
View File
@@ -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/<id> 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 : <dev>/battery props: charge(i),isCharging(b); sig: refreshed(b,i)
-- conn : <dev>/connectivity_report props: cellularNetworkType(s),
-- cellularNetworkStrength(i); sig: refreshed(s,i)
-- share : <dev>/share methods: shareUrl(s),shareText(s); sig: shareReceived(s)
-- ping : <dev>/ping methods: sendPing(), sendPing(s)
-- sftp : <dev>/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 : (<true>,)
-- 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: (<value>,)
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/<id>/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("<int32 " .. tostring(offset) .. ">"),
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 "
.. "<int32 " .. tostring(vol) .. ">",
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 <entry> <target> <event> [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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+89
View File
@@ -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()
+118
View File
@@ -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"
}
}
+133
View File
@@ -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()