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:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user