Krolyxon
Commit 0e3807e

Branches

Refs →

aurora0e3807e

aurora: removable drive and phone notifications

Author
krolyxon
Date
2026-10-25 15:15 +0530
Parent
8be0991
Commit
0e3807e742ed95d45c0ea268498d3fe11170f6fb

+234 −0

5 files changed

  1. A .config/quickshell/aurora/Devices.qml +192 −0
  2. M .config/quickshell/aurora/SysPage.qml +27 −0
  3. M .config/quickshell/aurora/Theme.qml +3 −0
  4. M .config/quickshell/aurora/shell.qml +3 −0
  5. M .local/bin/mounter +9 −0
A .config/quickshell/aurora/Devices.qml View
@@ -0,0 +1,192 @@
1+pragma Singleton
2+import QtQuick
3+import Quickshell
4+import Quickshell.Io
5+
6+// Removable drives (udisks) and phones (gvfs MTP). `udevadm monitor` triggers a rescan; a device that
7+// wasn't there before gets a notification with mount/open buttons (notify-send -A prints the clicked one).
8+// Mounting goes through `mounter DEV|URI` (LUKS passphrase in the runner, chown offer, its own
9+// notifications), so mounts land in /run/media/$USER like everywhere else. Nothing is automounted.
10+Singleton {
11+ id: root
12+
13+ // {key, dev, disk, label, size, fstype, mount, locked, luksParent}: one per filesystem on a hotplug disk
14+ property var drives: []
15+ // {key, uri, name, mounted, path}
16+ property var phones: []
17+ property bool scanned: false
18+ readonly property string gvfs: (Quickshell.env("XDG_RUNTIME_DIR") || "/run/user/1000") + "/gvfs/"
19+
20+ function title(d) { return d.label !== "" ? d.label : d.dev.replace("/dev/", "") }
21+ function phonePath(uri) { return gvfs + "mtp:host=" + uri.replace(/^mtp:\/\//, "").replace(/\/$/, "") }
22+
23+ function rescan() { if (!scanProc.running) scanProc.running = true; else again.restart() }
24+
25+ // ---- actions
26+ function mount(key, thenOpen) {
27+ run(["mounter", key], "", () => { if (thenOpen) openLater.key = key })
28+ }
29+ function open(key) {
30+ for (var i = 0; i < drives.length; i++)
31+ if (drives[i].key === key && drives[i].mount !== "") { xdgOpen(drives[i].mount); return true }
32+ for (i = 0; i < phones.length; i++)
33+ if (phones[i].key === key && phones[i].mounted) { xdgOpen(phones[i].path); return true }
34+ return false
35+ }
36+ function xdgOpen(path) { Quickshell.execDetached(["xdg-open", path]) }
37+ // unmount every filesystem of the disk, lock its LUKS containers, power it off
38+ function eject(key) {
39+ if (key.indexOf("mtp://") === 0) {
40+ run(["gio", "mount", "-u", key], "phone unmounted, safe to unplug", null)
41+ return
42+ }
43+ var disk = "", steps = []
44+ for (var i = 0; i < drives.length; i++) if (drives[i].key === key) disk = drives[i].disk
45+ if (disk === "") return
46+ for (i = 0; i < drives.length; i++) {
47+ var d = drives[i]
48+ if (d.disk !== disk) continue
49+ if (d.mount !== "") steps.push("u:" + d.dev)
50+ if (d.luksParent !== "") steps.push("l:" + d.luksParent)
51+ }
52+ steps.push((disk.indexOf("/dev/loop") === 0 ? "d:" : "p:") + disk) // an image: detach it
53+ run(["sh", "-c", 'for a; do case $a in u:*) udisksctl unmount -b "${a#u:}" ;; l:*) udisksctl lock -b "${a#l:}" ;;'
54+ + ' p:*) udisksctl power-off -b "${a#p:}" ;; d:*) udisksctl loop-delete -b "${a#d:}" ;; esac >/dev/null || exit; done', "sh"].concat(steps),
55+ "safe to unplug " + disk, null)
56+ }
57+
58+ // run a command, notify its error output on failure (or `okMsg` on success), rescan
59+ function run(cmd, okMsg, then) {
60+ var p = runC.createObject(root, { cmd: cmd, okMsg: okMsg, then: then })
61+ p.running = true
62+ }
63+ Component {
64+ id: runC
65+ Process {
66+ id: rp
67+ property var cmd: []
68+ property string okMsg: ""
69+ property var then: null
70+ command: ["sh", "-c", 'out=$("$@" 2>&1); rc=$?; printf "%s\\n%d" "$out" "$rc"', "sh"].concat(cmd)
71+ stdout: StdioCollector {
72+ onStreamFinished: {
73+ var l = text.split("\n"), rc = parseInt(l.pop())
74+ if (rc !== 0 && rp.cmd[0] !== "mounter") // mounter notifies its own errors
75+ Quickshell.execDetached(["notify-send", "-a", "aurora", "-u", "critical", "-i", "drive-removable-media",
76+ "device", l.join("\n").trim() || ("failed: " + rp.cmd.join(" "))])
77+ else if (rc === 0 && rp.okMsg !== "")
78+ Quickshell.execDetached(["notify-send", "-a", "aurora", "-i", "drive-removable-media", "device", rp.okMsg])
79+ if (rc === 0 && rp.then) rp.then()
80+ root.rescan()
81+ rp.destroy()
82+ }
83+ }
84+ }
85+ }
86+ // open after mount: wait for the rescan that follows it
87+ QtObject {
88+ id: openLater
89+ property string key: ""
90+ }
91+ onDrivesChanged: if (openLater.key !== "" && open(openLater.key)) openLater.key = ""
92+ onPhonesChanged: if (openLater.key !== "" && open(openLater.key)) openLater.key = ""
93+
94+ // ---- "new device" notification with buttons
95+ function announce(kind, key, text) {
96+ var acts = kind === "locked" ? ["-A", "mount=unlock"] : ["-A", "open=open", "-A", "mount=mount"]
97+ var p = askC.createObject(root, { key: key })
98+ p.command = ["notify-send", "-a", "aurora", "-i", kind === "phone" ? "phone" : "drive-removable-media"]
99+ .concat(acts, [kind === "phone" ? "phone connected" : "drive connected", text])
100+ p.running = true
101+ }
102+ Component {
103+ id: askC
104+ Process {
105+ id: ap
106+ property string key: ""
107+ stdout: StdioCollector {
108+ onStreamFinished: {
109+ var a = text.trim()
110+ if (a === "open") root.mount(ap.key, true)
111+ else if (a === "mount") root.mount(ap.key, false)
112+ ap.destroy()
113+ }
114+ }
115+ }
116+ }
117+
118+ // ---- scanning
119+ Process {
120+ id: udev
121+ command: ["udevadm", "monitor", "--udev", "--subsystem-match=block", "--subsystem-match=usb"]
122+ running: true
123+ stdout: SplitParser { onRead: _ => { debounce.restart(); late.restart() } }
124+ }
125+ Timer { id: debounce; interval: 700; onTriggered: root.rescan() }
126+ Timer { id: late; interval: 3000; onTriggered: root.rescan() } // gvfs sees phones a bit later
127+ Timer { id: again; interval: 300; onTriggered: root.rescan() }
128+ Timer { interval: 15000; running: true; repeat: true; triggeredOnStart: true; onTriggered: root.rescan() } // mounts done elsewhere
129+
130+ Process {
131+ id: scanProc
132+ command: ["sh", "-c", "lsblk -Jpo NAME,TYPE,FSTYPE,SIZE,LABEL,MOUNTPOINT,HOTPLUG,RM; echo '\x1e'; "
133+ + "gio mount -li 2>/dev/null; echo '\x1e'; gio mount -l 2>/dev/null"]
134+ stdout: StdioCollector { onStreamFinished: root.parse(text) }
135+ }
136+
137+ function truthy(v) { return v === true || v === "1" }
138+ function parse(text) {
139+ var parts = text.split("\x1e\n")
140+ if (parts.length < 3) return
141+ var nd = [], np = []
142+ var skip = ["swap", "LVM2_member", "linux_raid_member"], types = ["part", "disk", "rom", "crypt", "loop"]
143+ function walk(n, disk, hot, luks) {
144+ disk = disk || n.name
145+ hot = hot || truthy(n.hotplug) || truthy(n.rm) || n.type === "loop" // loop: attached images (udisksctl loop-setup)
146+ var kids = n.children || []
147+ var isLuks = n.fstype === "crypto_LUKS"
148+ if (hot && n.fstype && types.indexOf(n.type) >= 0 && skip.indexOf(n.fstype) < 0 && !(isLuks && kids.length)) {
149+ nd.push({ key: n.name, dev: n.name, disk: disk, label: n.label || "", size: n.size || "",
150+ fstype: (n.fstype || "").replace("crypto_", ""), mount: n.mountpoint || "",
151+ locked: isLuks, luksParent: luks })
152+ }
153+ for (var i = 0; i < kids.length; i++) walk(kids[i], disk, hot, isLuks ? n.name : luks)
154+ }
155+ try {
156+ var bd = JSON.parse(parts[0]).blockdevices || []
157+ for (var i = 0; i < bd.length; i++) walk(bd[i], "", false, "")
158+ } catch (e) { return }
159+
160+ // phones: "Volume(0): Pixel 7" ... "activation_root=mtp://Google_Pixel_7_XXXX/"; mounted ones: "Mount(0): Pixel 7 -> mtp://..."
161+ var name = "", l = parts[1].split("\n")
162+ for (i = 0; i < l.length; i++) {
163+ var m = l[i].match(/^\s*Volume\(\d+\): (.*)$/)
164+ if (m) { name = m[1]; continue }
165+ m = l[i].match(/^\s*activation_root=(mtp:\/\/.*)$/)
166+ if (m) np.push({ key: m[1], uri: m[1], name: name, mounted: false, path: phonePath(m[1]) })
167+ }
168+ l = parts[2].split("\n")
169+ for (i = 0; i < l.length; i++) {
170+ m = l[i].match(/-> (mtp:\/\/\S*)/)
171+ if (!m) continue
172+ for (var j = 0; j < np.length; j++) if (np[j].uri === m[1]) np[j].mounted = true
173+ }
174+
175+ if (scanned) {
176+ var old = {}
177+ for (i = 0; i < drives.length; i++) old[drives[i].key] = true
178+ for (i = 0; i < phones.length; i++) old[phones[i].key] = true
179+ for (i = 0; i < nd.length; i++) {
180+ var d = nd[i]
181+ if (old[d.key] || d.mount !== "") continue
182+ announce(d.locked ? "locked" : "drive", d.key,
183+ title(d) + " " + d.size + " (" + d.fstype + ")" + (d.locked ? ", encrypted" : ""))
184+ }
185+ for (i = 0; i < np.length; i++)
186+ if (!old[np[i].key] && !np[i].mounted) announce("phone", np[i].key, np[i].name)
187+ }
188+ if (JSON.stringify(nd) !== JSON.stringify(drives)) drives = nd
189+ if (JSON.stringify(np) !== JSON.stringify(phones)) phones = np
190+ scanned = true
191+ }
192+}
M .config/quickshell/aurora/SysPage.qml View
@@ -52,6 +52,33 @@ Flickable {
5252 }
5353 }
5454
55+ // removable drives + phones (Devices): mount / open / eject
56+ SectionLabel { text: "removable"; Layout.topMargin: 6; visible: Devices.drives.length + Devices.phones.length > 0 }
57+ Repeater {
58+ model: Devices.drives.concat(Devices.phones)
59+ delegate: ListRow {
60+ id: dr
61+ required property var modelData
62+ readonly property bool phone: modelData.uri !== undefined
63+ readonly property bool mounted: phone ? modelData.mounted : modelData.mount !== ""
64+ icon: phone ? Theme.iPhone : (modelData.locked ? Theme.iLock : Theme.iUsb)
65+ title: phone ? modelData.name : Devices.title(modelData)
66+ subtitle: phone ? (mounted ? modelData.path : "not mounted")
67+ : modelData.size + " " + modelData.fstype + " " + (mounted ? modelData.mount : (modelData.locked ? "locked" : "not mounted"))
68+ current: mounted
69+ onClicked: if (mounted) Devices.open(modelData.key); else Devices.mount(modelData.key, true)
70+ IconBtn {
71+ icon: Theme.iFolderOpen
72+ onClicked: if (dr.mounted) Devices.open(dr.modelData.key); else Devices.mount(dr.modelData.key, true)
73+ }
74+ IconBtn {
75+ icon: Theme.iEject
76+ visible: dr.mounted || !dr.phone
77+ onClicked: Devices.eject(dr.modelData.key)
78+ }
79+ }
80+ }
81+
5582 BatteryInfo { Layout.topMargin: 6 }
5683
5784 ColumnLayout {
M .config/quickshell/aurora/Theme.qml View
@@ -171,6 +171,9 @@ Singleton {
171171 readonly property string iChip: cp(0xF061A)
172172 readonly property string iMem: cp(0xF035B)
173173 readonly property string iDisk: cp(0xF02CA)
174+ readonly property string iUsb: cp(0xF129B)
175+ readonly property string iEject: cp(0xF01EA)
176+ readonly property string iFolderOpen: cp(0xF0770)
174177 readonly property string iGpu: cp(0xF08AE)
175178 readonly property string iLeaf: cp(0xF032A)
176179 readonly property string iBalance: cp(0xF05D1)
M .config/quickshell/aurora/shell.qml View
@@ -1,3 +1,5 @@
1+//@ pragma IconTheme Yaru
2+// ^ same as gtk-3.0: notify-send -i names (drive-removable-media, network-wireless, ...) resolve through it
13 import QtQuick
24 import Quickshell
35 import Quickshell.Io
@@ -22,6 +24,7 @@ ShellRoot {
2224 // touch singletons that must start with the shell (Idle creates ~/.config/aurora/hypridle.conf)
2325 readonly property string idleFile: Idle.file
2426 readonly property bool batCare: Battery.ready // charge limit + low battery warnings
27+ readonly property bool devWatch: Devices.scanned // removable drive notifications
2528 readonly property var tabNames: ["home", "audio", "net", "bt", "sys", "notif"]
2629 // settings app (a normal window): open state + shown section
2730 property bool settingsOpen: false
M .local/bin/mounter View
@@ -3,6 +3,7 @@
33 # udisks mounts drives to /run/media/$USER/<label> (fstab entries to their fstab
44 # target) and unlocks LUKS with a passphrase asked in aurora's runner; phones go
55 # through gvfs (/run/user/$UID/gvfs/mtp:host=...). Root is asked via polkit. See unmounter.
6+# `mounter DEV|mtp://URI` mounts that one without the menu (aurora's device notifications use it).
67
78 menu() { aurora-run -d -p "$1"; }
89 notify() { notify-send -a mounter "$@"; }
@@ -70,6 +71,14 @@ mountdrive() {
7071 notify "💾 Mounted" "$dev → ${mp:-$out}"
7172 }
7273
74+if [ -n "$1" ]; then
75+ case "$1" in
76+ mtp://*) mountphone "$1" ;;
77+ *) mountdrive "$1" ;;
78+ esac
79+ exit 0
80+fi
81+
7382 list="$(drives; phones)"
7483 [ -z "$list" ] && { notify "💾 Mounter" "Nothing to mount."; exit 0; }
7584