Krolyxon
Commit 84eb284

Branches

Refs →

aurora.configquickshellauroraDevices.qml

201 lines · 9.5 KB · QML

1pragma Singleton
2import QtQuick
3import Quickshell
4import 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.
10Singleton {
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 // a LUKS key (/dev/sda2) also matches its unlocked mapper, whose row replaces it after mounting
30 function open(key) {
31 for (var i = 0; i < drives.length; i++)
32 if ((drives[i].key === key || drives[i].luksParent === key) && drives[i].mount !== "") { xdgOpen(drives[i].mount); return true }
33 for (i = 0; i < phones.length; i++)
34 if (phones[i].key === key && phones[i].mounted) { xdgOpen(phones[i].path); return true }
35 return false
36 }
37 function xdgOpen(path) { Session.launch(["xdg-open", path]) }
38 // unmount this filesystem and lock its LUKS container; the disk stays powered, so it can be mounted again
39 function eject(key) {
40 if (key.indexOf("mtp://") === 0) {
41 run(["gio", "mount", "-u", key], "phone unmounted, safe to unplug", null)
42 return
43 }
44 var d = null, steps = []
45 for (var i = 0; i < drives.length; i++) if (drives[i].key === key) d = drives[i]
46 if (!d) return
47 if (d.mount !== "") steps.push("u:" + d.dev)
48 if (d.luksParent !== "") steps.push("l:" + d.luksParent)
49 if (steps.length === 0) return
50 run(["sh", "-c", 'for a; do case $a in u:*) udisksctl unmount -b "${a#u:}" ;; l:*) udisksctl lock -b "${a#l:}" ;;'
51 + ' esac >/dev/null || exit; done', "sh"].concat(steps),
52 title(d) + (d.luksParent !== "" ? " locked" : " unmounted"), null)
53 }
54 // anything on the disk still mounted or unlocked
55 function diskBusy(disk) {
56 for (var i = 0; i < drives.length; i++)
57 if (drives[i].disk === disk && (drives[i].mount !== "" || drives[i].luksParent !== "")) return true
58 return false
59 }
60 // power the disk off (an image: detach it). It disappears until replugged.
61 function powerOff(disk) {
62 if (diskBusy(disk)) return
63 run(["udisksctl", disk.indexOf("/dev/loop") === 0 ? "loop-delete" : "power-off", "-b", disk],
64 "safe to unplug " + disk, null)
65 }
66
67 // run a command, notify its error output on failure (or `okMsg` on success), rescan
68 function run(cmd, okMsg, then) {
69 var p = runC.createObject(root, { cmd: cmd, okMsg: okMsg, then: then })
70 p.running = true
71 }
72 Component {
73 id: runC
74 Process {
75 id: rp
76 property var cmd: []
77 property string okMsg: ""
78 property var then: null
79 command: ["sh", "-c", 'out=$("$@" 2>&1); rc=$?; printf "%s\\n%d" "$out" "$rc"', "sh"].concat(cmd)
80 stdout: StdioCollector {
81 onStreamFinished: {
82 var l = text.split("\n"), rc = parseInt(l.pop())
83 if (rc !== 0 && rp.cmd[0] !== "mounter") // mounter notifies its own errors
84 Quickshell.execDetached(["notify-send", "-a", "aurora", "-u", "critical", "-i", "drive-removable-media",
85 "device", l.join("\n").trim() || ("failed: " + rp.cmd.join(" "))])
86 else if (rc === 0 && rp.okMsg !== "")
87 Quickshell.execDetached(["notify-send", "-a", "aurora", "-i", "drive-removable-media", "device", rp.okMsg])
88 if (rc === 0 && rp.then) rp.then()
89 root.rescan()
90 rp.destroy()
91 }
92 }
93 }
94 }
95 // open after mount: wait for the rescan that follows it
96 QtObject {
97 id: openLater
98 property string key: ""
99 }
100 onDrivesChanged: if (openLater.key !== "" && open(openLater.key)) openLater.key = ""
101 onPhonesChanged: if (openLater.key !== "" && open(openLater.key)) openLater.key = ""
102
103 // ---- "new device" notification with buttons
104 function announce(kind, key, text) {
105 var acts = kind === "locked" ? ["-A", "mount=unlock"] : ["-A", "open=open", "-A", "mount=mount"]
106 var p = askC.createObject(root, { key: key })
107 p.command = ["notify-send", "-a", "aurora", "-i", kind === "phone" ? "phone" : "drive-removable-media"]
108 .concat(acts, [kind === "phone" ? "phone connected" : "drive connected", text])
109 p.running = true
110 }
111 Component {
112 id: askC
113 Process {
114 id: ap
115 property string key: ""
116 stdout: StdioCollector {
117 onStreamFinished: {
118 var a = text.trim()
119 if (a === "open") root.mount(ap.key, true)
120 else if (a === "mount") root.mount(ap.key, false)
121 ap.destroy()
122 }
123 }
124 }
125 }
126
127 // ---- scanning
128 Process {
129 id: udev
130 command: ["udevadm", "monitor", "--udev", "--subsystem-match=block", "--subsystem-match=usb"]
131 running: true
132 stdout: SplitParser { onRead: _ => { debounce.restart(); late.restart() } }
133 }
134 Timer { id: debounce; interval: 700; onTriggered: root.rescan() }
135 Timer { id: late; interval: 3000; onTriggered: root.rescan() } // gvfs sees phones a bit later
136 Timer { id: again; interval: 300; onTriggered: root.rescan() }
137 Timer { interval: 15000; running: true; repeat: true; triggeredOnStart: true; onTriggered: root.rescan() } // mounts done elsewhere
138
139 Process {
140 id: scanProc
141 command: ["sh", "-c", "lsblk -Jpo NAME,TYPE,FSTYPE,SIZE,LABEL,MOUNTPOINT,HOTPLUG,RM; echo '\x1e'; "
142 + "gio mount -li 2>/dev/null; echo '\x1e'; gio mount -l 2>/dev/null"]
143 stdout: StdioCollector { onStreamFinished: root.parse(text) }
144 }
145
146 function truthy(v) { return v === true || v === "1" }
147 function parse(text) {
148 var parts = text.split("\x1e\n")
149 if (parts.length < 3) return
150 var nd = [], np = []
151 var skip = ["swap", "LVM2_member", "linux_raid_member"], types = ["part", "disk", "rom", "crypt", "loop"]
152 function walk(n, disk, hot, luks) {
153 disk = disk || n.name
154 hot = hot || truthy(n.hotplug) || truthy(n.rm) || n.type === "loop" // loop: attached images (udisksctl loop-setup)
155 var kids = n.children || []
156 var isLuks = n.fstype === "crypto_LUKS"
157 if (hot && n.fstype && types.indexOf(n.type) >= 0 && skip.indexOf(n.fstype) < 0 && !(isLuks && kids.length)) {
158 nd.push({ key: n.name, dev: n.name, disk: disk, label: n.label || "", size: n.size || "",
159 fstype: (n.fstype || "").replace("crypto_", ""), mount: n.mountpoint || "",
160 locked: isLuks, luksParent: luks })
161 }
162 for (var i = 0; i < kids.length; i++) walk(kids[i], disk, hot, isLuks ? n.name : luks)
163 }
164 try {
165 var bd = JSON.parse(parts[0]).blockdevices || []
166 for (var i = 0; i < bd.length; i++) walk(bd[i], "", false, "")
167 } catch (e) { return }
168
169 // phones: "Volume(0): Pixel 7" ... "activation_root=mtp://Google_Pixel_7_XXXX/"; mounted ones: "Mount(0): Pixel 7 -> mtp://..."
170 var name = "", l = parts[1].split("\n")
171 for (i = 0; i < l.length; i++) {
172 var m = l[i].match(/^\s*Volume\(\d+\): (.*)$/)
173 if (m) { name = m[1]; continue }
174 m = l[i].match(/^\s*activation_root=(mtp:\/\/.*)$/)
175 if (m) np.push({ key: m[1], uri: m[1], name: name, mounted: false, path: phonePath(m[1]) })
176 }
177 l = parts[2].split("\n")
178 for (i = 0; i < l.length; i++) {
179 m = l[i].match(/-> (mtp:\/\/\S*)/)
180 if (!m) continue
181 for (var j = 0; j < np.length; j++) if (np[j].uri === m[1]) np[j].mounted = true
182 }
183
184 if (scanned) {
185 var old = {}
186 for (i = 0; i < drives.length; i++) old[drives[i].key] = true
187 for (i = 0; i < phones.length; i++) old[phones[i].key] = true
188 for (i = 0; i < nd.length; i++) {
189 var d = nd[i]
190 if (old[d.key] || d.mount !== "") continue
191 announce(d.locked ? "locked" : "drive", d.key,
192 title(d) + " " + d.size + " (" + d.fstype + ")" + (d.locked ? ", encrypted" : ""))
193 }
194 for (i = 0; i < np.length; i++)
195 if (!old[np[i].key] && !np[i].mounted) announce("phone", np[i].key, np[i].name)
196 }
197 if (JSON.stringify(nd) !== JSON.stringify(drives)) drives = nd
198 if (JSON.stringify(np) !== JSON.stringify(phones)) phones = np
199 scanned = true
200 }
201}