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