Files
omarchycn/shell/plugins/panels/bluetooth/Model.js
T
96bbe53634 Fix panel delegate segfault and the network panel's open stall (#6605)
* fix(network): drop the redundant rescan on the bar click

Opening from the bar ran open() and then a bare refresh(). open() already
triggers onOpenedChanged -> refresh(true), which defers the PHY scan by
disabling the scanner and re-enabling it from scanRestart. The bare
refresh() that followed defaults scanWifi to false, so it took the other
branch and set wifiDevice.scannerEnabled synchronously on the click frame,
undoing the deferral and stalling the open on NetworkManager's access-point
flood. It also double-started the DNS and band probes.

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(network): keep wifi rows QObject-free to prevent a delegate crash

wifiRow() embedded the WifiNetwork QObject in the row it returns, and those
rows are list-model data, so every delegate held a live QObject wrapper in a
var property. When NetworkManager churns the list -- a scan's access-point
flood, an AP disappearing -- the object can be destroyed while a delegate is
still incubating, and quickshell segfaults in QObjectWrapper::wrap_slowPath
on the dangling wrapper.

Project primitives only and resolve the backend object at action time via
the existing networkForSsid(). Both failNetworkAction() and
checkActionCompletion() already no-op on a null network, so a row whose
network has since vanished is handled the same way it was before.

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bluetooth): keep device rows QObject-free to prevent a delegate crash

Same crash class as the wifi rows: scrollRows embedded the BlueZ Device
QObject in list-model data, so every delegate held a live wrapper in a var
property. Discovery churn -- a scan timeout dropping a device, an unpair --
can destroy the object while a delegate is still incubating, and quickshell
segfaults on the dangling wrapper.

Project primitives for both the scroll rows and the connected rows, and
resolve the backend object by address in deviceFor() for the click actions.
The keyboard flow already went through deviceAt(), which reads the live
device arrays directly rather than model data, so it is untouched.

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(network): guard row disconnects against a vanished network

Row activation resolved the WifiNetwork with networkForSsid() and passed the
result straight to disconnect(), which falls back to connectedWifiNetwork
when handed null. A row is a primitive snapshot, so scan churn can remove its
backing object while the row is still on screen -- activating it then tore
down whatever happened to be connected at that moment rather than doing
nothing.

Route both row paths through disconnectRow(), which resolves first and only
acts when the row still maps to a live network. disconnect() keeps its
fallback for callers that mean "drop the current connection".

Also covers the bar-click open path, which had no regression: the suite
already asserts against Panel.qml source, so assert the closed branch calls
open() alone and never a second refresh().

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: shrijit <shrijitsrivastav@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:32:36 +02:00

178 lines
5.1 KiB
JavaScript

function deviceLabel(device) {
if (!device) return ""
return String(device.deviceName || device.name || "").trim()
}
function toArray(values) {
if (!values) return []
if (Array.isArray(values)) return values.slice()
var length = Number(values.length || 0)
if (!isFinite(length) || length <= 0) return []
var list = []
for (var i = 0; i < length; i++) list.push(values[i])
return list
}
function isUuidLike(value) {
var text = String(value || "").trim()
if (text === "") return false
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)
|| /^[0-9a-f]{32}$/i.test(text)
|| /^0x[0-9a-f]{4,32}$/i.test(text)
|| /^0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb$/i.test(text)
}
function isAddressLike(value) {
var text = String(value || "").trim()
return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text)
}
function normalizedAddress(value) {
return String(value || "").trim().toLowerCase().replace(/[^0-9a-f]/g, "")
}
function hasHumanName(device) {
var label = deviceLabel(device)
return label !== "" && !isUuidLike(label) && !isAddressLike(label)
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function nodeText(node) {
var props = nodeProps(node)
return [
node ? node.name : "",
node ? node.description : "",
node ? node.nickname : "",
node ? node.nick : "",
props["node.name"],
props["node.description"],
props["node.nick"],
props["device.name"],
props["device.description"],
props["device.product.name"],
props["device.alias"],
props["device.string"],
props["api.bluez5.address"],
props["bluez5.address"],
props["media.name"]
].join(" ").toLowerCase()
}
function bluetoothSinkMatchesDevice(node, device) {
if (!node || !node.isSink || node.isStream || !device) return false
var address = normalizedAddress(device.address)
var text = nodeText(node)
if (address !== "" && normalizedAddress(text).indexOf(address) !== -1) return true
var label = deviceLabel(device).toLowerCase()
return label !== "" && text.indexOf(label) !== -1
}
function sortedByLabel(devices) {
var list = toArray(devices)
list.sort(function(a, b) { return deviceLabel(a).localeCompare(deviceLabel(b)) })
return list
}
// Primitives-only projection of a BlueZ device for list-model rows. Holding
// the Device QObject in model data puts a live wrapper into every delegate's
// var property, and BlueZ churn (discovery timeouts, unpair) can destroy the
// object while a delegate is still incubating, which segfaults quickshell.
// Actions resolve the backend object via Panel.deviceFor().
function deviceRow(d) {
if (!d) return null
return {
address: d.address || "",
name: d.name || "",
deviceName: d.deviceName || "",
connected: !!d.connected,
state: d.state !== undefined ? d.state : -1,
batteryAvailable: !!d.batteryAvailable,
battery: d.battery !== undefined ? d.battery : 0,
pairing: !!d.pairing
}
}
function deviceLists(devices) {
var values = toArray(devices)
var connected = []
var known = []
var discovered = []
for (var i = 0; i < values.length; i++) {
var d = values[i]
if (!d || !hasHumanName(d)) continue
if (d.connected) connected.push(d)
else if (d.paired || d.bonded || d.trusted) known.push(d)
else discovered.push(d)
}
return {
connected: sortedByLabel(connected),
known: sortedByLabel(known),
discovered: sortedByLabel(discovered)
}
}
function cloneMap(map) {
var next = ({})
for (var key in map || {}) next[key] = map[key]
return next
}
function pendingAction(actions, address) {
return address && actions && actions[address] ? actions[address] : ""
}
function withPendingAction(actions, address, action) {
var next = cloneMap(actions)
if (!address) return next
if (action) next[address] = action
else delete next[address]
return next
}
function visibleSections(lists, discovering) {
var sections = []
if (lists && lists.connected && lists.connected.length > 0) sections.push("connected")
if (lists && lists.known && lists.known.length > 0) sections.push("known")
if (discovering && lists && lists.discovered && lists.discovered.length > 0) sections.push("discovered")
return sections
}
function sectionDevices(lists, section) {
if (!lists) return []
if (section === "connected") return lists.connected || []
if (section === "known") return lists.known || []
if (section === "discovered") return lists.discovered || []
return []
}
if (typeof module !== "undefined") {
module.exports = {
deviceLabel: deviceLabel,
toArray: toArray,
isUuidLike: isUuidLike,
isAddressLike: isAddressLike,
normalizedAddress: normalizedAddress,
hasHumanName: hasHumanName,
nodeProps: nodeProps,
nodeText: nodeText,
bluetoothSinkMatchesDevice: bluetoothSinkMatchesDevice,
sortedByLabel: sortedByLabel,
deviceRow: deviceRow,
deviceLists: deviceLists,
cloneMap: cloneMap,
pendingAction: pendingAction,
withPendingAction: withPendingAction,
visibleSections: visibleSections,
sectionDevices: sectionDevices
}
}