This commit is contained in:
2026-05-28 21:45:21 +02:00
parent 653439ac24
commit a356a3d369
610 changed files with 205679 additions and 0 deletions
@@ -0,0 +1,72 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "../../Helpers/BluetoothUtils.js" as BluetoothUtils
QtObject {
id: root
// Controls
property bool enabled: false
property int intervalMs: 10000
property var connectedDevices: []
// Output cache and version for bindings
property var cache: ({}) // addr -> percent (0..100)
property int version: 0
// Internal rotation state
property int _index: 0
property string _currentAddr: ""
// Single process reused for RSSI queries
property Process rssiProcess: Process {
id: proc
running: false
stdout: StdioCollector {
id: out
}
onExited: function (exitCode, exitStatus) {
try {
var text = out.text || "";
var dbm = BluetoothUtils.parseRssiOutput(text);
if (root._currentAddr !== "" && dbm !== null) {
var pct = BluetoothUtils.dbmToPercent(dbm);
if (pct !== null) {
root.cache[root._currentAddr] = pct;
root.version++;
}
}
} catch (e) {} finally {
root._currentAddr = "";
}
}
}
// Periodic RSSI polling timer
property Timer rssiTimer: Timer {
interval: root.intervalMs
repeat: true
running: root.enabled
onTriggered: {
var list = root.connectedDevices || [];
if (!list || list.length === 0)
return;
if (root._index >= list.length)
root._index = 0;
var dev = list[root._index++];
if (!dev)
return;
var addr = BluetoothUtils.macFromDevice(dev);
if (!addr || addr.length < 7)
return;
if (proc.running)
return; // avoid overlap
root._currentAddr = addr;
proc.command = ["sh", "-c", `bluetoothctl info "${addr}"`];
try {
proc.running = true;
} catch (e) {}
}
}
}
@@ -0,0 +1,542 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Io
import "../../Helpers/BluetoothUtils.js" as BluetoothUtils
import qs.Commons
import qs.Services.System
import qs.Services.UI
Singleton {
id: root
readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
// Power/availability state
readonly property bool bluetoothAvailable: !!adapter
readonly property bool enabled: adapter?.enabled ?? false
readonly property bool blocked: adapter?.state === BluetoothAdapter.Blocked
// Exposed scanning flag for UI button state; reflects adapter discovery when available
readonly property bool scanningActive: adapter?.discovering ?? false
// Adapter discoverability (advertising) flag
readonly property bool discoverable: adapter?.discoverable ?? false
readonly property var devices: adapter ? adapter.devices : null
readonly property var connectedDevices: {
if (!adapter || !adapter.devices) {
return [];
}
return adapter.devices.values.filter(dev => dev && dev.connected);
}
// Experimental: besteffort RSSI polling for connected devices (without root)
// Enabled in debug mode or via user setting in Settings > Network
property bool rssiPollingEnabled: Settings?.data?.network?.bluetoothRssiPollingEnabled || Settings?.isDebug || false
// Interval can be configured from Settings; defaults to 60s
property int rssiPollIntervalMs: Settings?.data?.network?.bluetoothRssiPollIntervalMs || 60000
// RSSI helper subcomponent
property BluetoothRssi rssi: BluetoothRssi {
enabled: root.enabled && root.rssiPollingEnabled
intervalMs: root.rssiPollIntervalMs
connectedDevices: root.connectedDevices
}
// Tunables for CLI pairing/connect flow
property int pairWaitSeconds: 45
property int connectAttempts: 5
property int connectRetryIntervalMs: 2000
// Interaction state
property bool pinRequired: false
// Internal variables
property bool _discoveryWasRunning: false
property bool _ctlInit: false
property var _autoConnectQueue: []
// Persistent cache for per-device auto-connect toggle
property string cacheFile: Settings.cacheDir + "bluetooth_devices.json"
FileView {
id: cacheFileView
path: root.cacheFile
printErrors: false
JsonAdapter {
id: cacheAdapter
property var autoConnectSettings: ({})
}
}
// Handle system wakeup to force-poll and ensure state is up-to-date
Connections {
target: Time
function onResumed() {
Logger.i("Bluetooth", "System resumed - forcing state poll");
ctlPollTimer.restart();
}
}
// Track adapter state changes
Connections {
target: adapter
function onStateChanged() {
if (!adapter || adapter.state === BluetoothAdapter.Enabling || adapter.state === BluetoothAdapter.Disabling) {
return;
}
checkAirplaneMode();
}
function onEnabledChanged() {
if (adapter && adapter.enabled && Settings.data.network.bluetoothAutoConnect) {
autoConnectTimer.restart();
}
}
}
Connections {
target: Settings.data.network
function onBluetoothAutoConnectChanged() {
if (Settings.data.network.bluetoothAutoConnect && adapter && adapter.enabled) {
autoConnectTimer.restart();
} else {
autoConnectTimer.stop();
}
}
}
Component.onCompleted: {
Logger.i("Bluetooth", "Service started");
autoConnectTimer.restart();
}
Timer {
id: autoConnectTimer
interval: 1500
repeat: false
onTriggered: attemptAutoConnect()
}
Timer {
id: autoConnectStepTimer
interval: 500
repeat: false
onTriggered: {
var device = root._autoConnectQueue.shift();
if (device && device.paired && !device.connected && !device.blocked) {
Logger.i("Bluetooth", "Auto-connecting to:", device.name || device.deviceName);
connectDeviceWithTrust(device);
}
if (root._autoConnectQueue.length > 0) {
autoConnectStepTimer.restart();
}
}
}
Timer {
id: ctlPollTimer
interval: 250
running: false
onTriggered: {
if (!adapter || !ProgramCheckerService.bluetoothctlAvailable) {
return;
}
ctlPollProcess.running = true;
}
}
// Adapter power (enable/disable) via bluetoothctl
function setBluetoothEnabled(state) {
if (!adapter) {
Logger.d("Bluetooth", "Enable/Disable skipped: no adapter");
return;
}
try {
adapter.enabled = state;
Logger.i("Bluetooth", "SetBluetoothEnabled", state);
} catch (e) {
Logger.w("Bluetooth", "Enable/Disable failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.state-change-failed"));
}
}
// Check if airplane mode has been toggled
function checkAirplaneMode() {
var isAirplaneModeActive = !NetworkService.wifiEnabled && adapter.state === BluetoothAdapter.Blocked;
if (isAirplaneModeActive && !NetworkService.airplaneModeEnabled) {
NetworkService.airplaneModeToggled = true;
NetworkService.airplaneModeEnabled = true;
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.enabled"), "plane");
Logger.i("AirplaneMode", "Enabled");
} else if (!isAirplaneModeActive && NetworkService.airplaneModeEnabled) {
NetworkService.airplaneModeToggled = true;
NetworkService.airplaneModeEnabled = false;
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.disabled"), "plane-off");
Logger.i("AirplaneMode", "Disabled");
} else if (adapter.enabled) {
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.enabled"), "bluetooth");
Logger.d("Bluetooth", "Adapter enabled");
} else {
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.disabled"), "bluetooth-off");
Logger.d("Bluetooth", "Adapter disabled");
}
}
// Unify discovery controls
function setScanActive(active) {
if (!adapter) {
Logger.d("Bluetooth", "Scan request ignored: adapter unavailable");
return;
}
try {
if (active || adapter.discovering) { // Only attempt to set if activating, or if deactivating and currently currently discovering
adapter.discovering = active;
}
} catch (e) {
Logger.e("Bluetooth", "setScanActive failed", e);
}
}
// Toggle adapter discoverability (advertising visibility) via bluetoothctl
function setDiscoverable(state) {
if (!adapter) {
Logger.d("Bluetooth", "Discoverable change skipped: no adapter");
return;
}
try {
adapter.discoverable = state;
Logger.i("Bluetooth", "Discoverable state set to:", state);
} catch (e) {
Logger.w("Bluetooth", "Failed to change discoverable state", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.discoverable-change-failed"));
}
}
function sortDevices(devices) {
return devices.sort(function (a, b) {
var aName = a.name || a.deviceName || "";
var bName = b.name || b.deviceName || "";
var aHasRealName = aName.indexOf(" ") !== -1 && aName.length > 3;
var bHasRealName = bName.indexOf(" ") !== -1 && bName.length > 3;
if (aHasRealName && !bHasRealName) {
return -1;
}
if (!aHasRealName && bHasRealName) {
return 1;
}
var aSignal = (a.signalStrength !== undefined && a.signalStrength > 0) ? a.signalStrength : 0;
var bSignal = (b.signalStrength !== undefined && b.signalStrength > 0) ? b.signalStrength : 0;
return bSignal - aSignal;
});
}
function getDeviceIcon(device) {
if (!device) {
return "bt-device-generic";
}
return BluetoothUtils.deviceIcon(device.name || device.deviceName, device.icon);
}
function canConnect(device) {
if (!device) {
return false;
}
return !device.connected && (device.paired || device.trusted) && !device.pairing && !device.blocked;
}
function canDisconnect(device) {
if (!device) {
return false;
}
return device.connected && !device.pairing && !device.blocked;
}
// Textual signal quality (translated)
function getSignalStrength(device) {
var p = getSignalPercent(device);
if (p === null) {
return I18n.tr("bluetooth.panel.signal-text-unknown");
}
if (p >= 80) {
return I18n.tr("bluetooth.panel.signal-text-excellent");
}
if (p >= 60) {
return I18n.tr("bluetooth.panel.signal-text-good");
}
if (p >= 40) {
return I18n.tr("bluetooth.panel.signal-text-fair");
}
if (p >= 20) {
return I18n.tr("bluetooth.panel.signal-text-poor");
}
return I18n.tr("bluetooth.panel.signal-text-very-poor");
}
// Numeric helpers for UI rendering
function getSignalPercent(device) {
// Establish binding dependency so UI updates when RSSI cache changes
var _v = rssi.version;
return BluetoothUtils.signalPercent(device, rssi.cache, _v);
}
function getBatteryPercent(device) {
return BluetoothUtils.batteryPercent(device);
}
function getSignalIcon(device) {
var p = getSignalPercent(device);
return BluetoothUtils.signalIcon(p);
}
function isDeviceBusy(device) {
if (!device) {
return false;
}
return device.pairing || device.state === BluetoothDevice.Disconnecting || device.state === BluetoothDevice.Connecting;
}
// Return a stable unique key for a device (prefer MAC address)
function deviceKey(device) {
return BluetoothUtils.deviceKey(device);
}
// Deduplicate a list of devices using the stable key
function dedupeDevices(devList) {
return BluetoothUtils.dedupeDevices(devList);
}
// Separate capability helpers
function canPair(device) {
if (!device) {
return false;
}
return !device.connected && !device.paired && !device.trusted && !device.pairing && !device.blocked;
}
// Pairing and unpairing helpers
function pairDevice(device) {
if (!device) {
return;
}
ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.pairing"), "bluetooth");
try {
pairWithBluetoothctl(device);
} catch (e) {
Logger.w("Bluetooth", "pairDevice failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.pair-failed"));
}
}
function submitPin(pin) {
if (pairingProcess.running) {
pairingProcess.write(pin + "\n");
root.pinRequired = false;
}
}
function cancelPairing() {
if (pairingProcess.running) {
pairingProcess.running = false;
}
root.pinRequired = false;
}
// Pair using bluetoothctl which registers its own BlueZ agent internally.
function pairWithBluetoothctl(device) {
if (!device) {
return;
}
var addr = BluetoothUtils.macFromDevice(device);
if (!addr || addr.length < 7) {
Logger.w("Bluetooth", "pairWithBluetoothctl: no valid address for device");
return;
}
Logger.i("Bluetooth", "pairWithBluetoothctl", addr);
if (pairingProcess.running) {
pairingProcess.running = false;
}
root.pinRequired = false;
const pairWait = Math.max(5, Number(root.pairWaitSeconds) | 0);
const attempts = Math.max(1, Number(root.connectAttempts) | 0);
const intervalMs = Math.max(500, Number(root.connectRetryIntervalMs) | 0);
const intervalSec = Math.max(1, Math.round(intervalMs / 1000));
// Temporarily pause discovery during pair/connect to reduce HCI churn
root._discoveryWasRunning = root.scanningActive;
if (root.scanningActive) {
root.setScanActive(false);
}
const scriptPath = Quickshell.shellDir + "/Scripts/python/src/network/bluetooth-pair.py";
pairingProcess.command = ["python3", scriptPath, String(addr), String(pairWait), String(attempts), String(intervalSec)];
pairingProcess.running = true;
}
// Helper to run bluetoothctl and scripts with consistent error logging
function btExec(args) {
try {
Quickshell.execDetached(args);
} catch (e) {
Logger.w("Bluetooth", "btExec failed", e);
}
}
// Status key for a device (untranslated)
function getStatusKey(device) {
if (!device) {
return "";
}
try {
if (device.pairing)
return "pairing";
if (device.blocked)
return "blocked";
if (device.state === BluetoothDevice.Connecting)
return "connecting";
if (device.state === BluetoothDevice.Disconnecting)
return "disconnecting";
} catch (_) {}
return "";
}
function unpairDevice(device) {
forgetDevice(device);
}
function getDeviceAutoConnect(device) {
if (!device || !device.address || !cacheAdapter.autoConnectSettings) {
return false;
}
const mac = device.address;
const settings = cacheAdapter.autoConnectSettings[mac];
return settings ? !!settings.autoConnect : false;
}
function setDeviceAutoConnect(device, enabled) {
if (!device || !device.address) {
return;
}
const mac = device.address;
let settings = cacheAdapter.autoConnectSettings || ({});
if (enabled) {
settings[mac] = {
autoConnect: true,
deviceName: device.name || device.deviceName || ""
};
} else {
delete settings[mac];
}
cacheAdapter.autoConnectSettings = settings;
cacheFileView.writeAdapter();
}
function attemptAutoConnect() {
if (NetworkService.airplaneModeEnabled || !adapter || !adapter.enabled || !Settings.data.network.bluetoothAutoConnect) {
return;
}
_autoConnectQueue = adapter.devices.values.filter(dev => dev && dev.paired && !dev.connected && !dev.blocked && getDeviceAutoConnect(dev) === true);
if (root._autoConnectQueue.length > 0) {
autoConnectStepTimer.restart();
}
}
function connectDeviceWithTrust(device) {
if (!device) {
return;
}
try {
device.trusted = true;
device.connect();
} catch (e) {
Logger.w("Bluetooth", "connectDeviceWithTrust failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.connect-failed"));
}
}
function disconnectDevice(device) {
if (!device) {
return;
}
try {
device.disconnect();
} catch (e) {
Logger.w("Bluetooth", "disconnectDevice failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.disconnect-failed"));
}
}
function forgetDevice(device) {
if (!device) {
return;
}
try {
device.trusted = false;
device.forget();
} catch (e) {
Logger.w("Bluetooth", "forgetDevice failed", e);
ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.forget-failed"));
}
}
// Poll Bluetooth power state with bluetoothctl to handle a Quickshell bug on resume after suspend
Process {
id: ctlPollProcess
command: ["bluetoothctl", "show"]
running: false
stdout: StdioCollector {
onStreamFinished: {
var powered = false;
var mp = text.match(/\bPowered:\s*(yes|no)\b/i);
if (mp) {
powered = mp[1].toLowerCase() === 'yes';
}
if (adapter.enabled !== powered) {
adapter.enabled = powered;
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
Logger.d("Bluetooth", "Failed to parse bluetoothctl show output" + text);
}
}
}
}
// Interactive pairing process
Process {
id: pairingProcess
stdout: SplitParser {
onRead: data => {
Logger.d("Bluetooth", data);
if (data.indexOf("PIN_REQUIRED") !== -1) {
root.pinRequired = true;
Logger.i("Bluetooth", "PIN required for pairing");
}
}
}
onExited: {
root.pinRequired = false;
Logger.i("Bluetooth", "Pairing process exited.");
// Restore discovery if we paused it
if (root._discoveryWasRunning) {
root.setScanActive(true);
}
root._discoveryWasRunning = false;
}
environment: ({
"LC_ALL": "C"
})
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
Singleton {
id: root
property var connections: ({})
property bool refreshing: false
property bool connecting: false
property bool disconnecting: false
property string connectingUuid: ""
property string disconnectingUuid: ""
property string lastError: ""
property bool refreshPending: false
readonly property var activeConnections: {
const result = [];
const map = connections;
for (const key in map) {
const conn = map[key];
if (conn && conn.active) {
result.push(conn);
}
}
return result;
}
readonly property var inactiveConnections: {
const result = [];
const map = connections;
for (const key in map) {
const conn = map[key];
if (conn && !conn.active) {
result.push(conn);
}
}
return result;
}
readonly property bool hasActiveConnection: activeConnections.length > 0
Timer {
id: refreshTimer
interval: 5000
running: true
repeat: true
onTriggered: refresh()
}
Timer {
id: delayedRefreshTimer
interval: 1000
repeat: false
onTriggered: refresh()
}
Component.onCompleted: {
Logger.i("VPN", "Service started");
refresh();
}
function refresh() {
if (refreshing) {
refreshPending = true;
return;
}
refreshing = true;
lastError = "";
refreshProcess.running = true;
}
function connect(uuid) {
if (connecting || !uuid) {
return;
}
const conn = connections[uuid];
if (!conn) {
return;
}
connecting = true;
connectingUuid = uuid;
lastError = "";
connectProcess.uuid = uuid;
connectProcess.name = conn.name;
connectProcess.running = true;
}
function disconnect(uuid) {
if (disconnecting || !uuid) {
return;
}
const conn = connections[uuid];
if (!conn) {
return;
}
disconnecting = true;
disconnectingUuid = uuid;
lastError = "";
disconnectProcess.uuid = uuid;
disconnectProcess.name = conn.name;
disconnectProcess.running = true;
}
function toggle(uuid) {
const conn = connections[uuid];
if (!conn) {
return;
}
if (conn.active) {
disconnect(uuid);
} else {
connect(uuid);
}
}
function setConnection(uuid, data) {
if (!uuid) {
return;
}
const map = Object.assign({}, connections);
if (map[uuid]) {
map[uuid] = Object.assign({}, map[uuid], data);
connections = map;
}
}
function scheduleRefresh(interval) {
delayedRefreshTimer.interval = interval;
delayedRefreshTimer.restart();
}
Process {
id: refreshProcess
running: false
command: ["nmcli", "-t", "-f", "NAME,UUID,TYPE,DEVICE", "connection", "show"]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.split("\n");
const map = {};
for (let i = 0; i < lines.length; ++i) {
const line = lines[i].trim();
if (!line) {
continue;
}
const lastColonIdx = line.lastIndexOf(":");
if (lastColonIdx === -1) {
continue;
}
const device = line.substring(lastColonIdx + 1);
const remaining = line.substring(0, lastColonIdx);
const secondLastColonIdx = remaining.lastIndexOf(":");
if (secondLastColonIdx === -1) {
continue;
}
const type = remaining.substring(secondLastColonIdx + 1);
if (type !== "vpn" && type !== "wireguard") {
continue;
}
const remaining2 = remaining.substring(0, secondLastColonIdx);
const thirdLastColonIdx = remaining2.lastIndexOf(":");
if (thirdLastColonIdx === -1) {
continue;
}
const uuid = remaining2.substring(thirdLastColonIdx + 1);
const name = remaining2.substring(0, thirdLastColonIdx);
if (!uuid || !name) {
continue;
}
const active = device && device !== "--";
map[uuid] = {
"uuid": uuid,
"name": name,
"device": device,
"active": active
};
}
connections = map;
const pending = refreshPending;
refreshing = false;
refreshPending = false;
if (pending) {
scheduleRefresh(200);
}
}
}
stderr: StdioCollector {
onStreamFinished: {
const pending = refreshPending;
refreshing = false;
refreshPending = false;
if (text.trim()) {
lastError = text.split("\n")[0].trim();
Logger.w("VPN", "Refresh error: " + text);
}
if (pending) {
scheduleRefresh(2000);
}
}
}
}
Process {
id: connectProcess
property string uuid: ""
property string name: ""
running: false
command: ["nmcli", "connection", "up", "uuid", uuid]
stdout: StdioCollector {
onStreamFinished: {
const output = text.trim();
if (!output || (!output.includes("successfully activated") && !output.includes("Connection successfully"))) {
return;
}
setConnection(connectProcess.uuid, {
"active": true
});
connecting = false;
connectingUuid = "";
lastError = "";
Logger.i("VPN", "Connected to " + connectProcess.name);
ToastService.showNotice(connectProcess.name, I18n.tr("toast.vpn.connected", {
"name": connectProcess.name
}), "shield-lock");
scheduleRefresh(1000);
}
}
stderr: StdioCollector {
onStreamFinished: {
const trimmed = text.trim();
if (trimmed) {
lastError = trimmed.split("\n")[0].trim();
Logger.w("VPN", "Connect error: " + trimmed);
ToastService.showWarning(connectProcess.name, lastError);
}
connecting = false;
connectingUuid = "";
}
}
}
Process {
id: disconnectProcess
property string uuid: ""
property string name: ""
running: false
command: ["nmcli", "connection", "down", "uuid", uuid]
stdout: StdioCollector {
onStreamFinished: {
Logger.i("VPN", "Disconnected from " + disconnectProcess.name);
setConnection(disconnectProcess.uuid, {
"active": false,
"device": ""
});
disconnecting = false;
disconnectingUuid = "";
lastError = "";
ToastService.showNotice(disconnectProcess.name, I18n.tr("toast.vpn.disconnected", {
"name": disconnectProcess.name
}), "shield-off");
scheduleRefresh(1000);
}
}
stderr: StdioCollector {
onStreamFinished: {
const trimmed = text.trim();
if (trimmed) {
lastError = trimmed.split("\n")[0].trim();
Logger.w("VPN", "Disconnect error: " + trimmed);
ToastService.showWarning(disconnectProcess.name, lastError);
}
disconnecting = false;
disconnectingUuid = "";
}
}
}
}