aboutsummaryrefslogtreecommitdiffstats
path: root/shared/Notify.qml
blob: 1abe57dfdeef592b887cb163d18c9db9262b5bfd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

pragma Singleton

import Quickshell
import Quickshell.Io
import QtQuick

// The notification daemon's state, read from the files it publishes under
// $XDG_RUNTIME_DIR/notifyd/. The files are the interface, the same convention
// the status registry set: the daemon writes them, this reads them, and
// notifyctl is the one path back for a mutation. Nothing here speaks D-Bus.
//
// A shell restart loses nothing: the daemon keeps running, the files stay, and
// this singleton reads them back.
Singleton {
    id: root

    readonly property string dir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/notifyd"

    // Parsed whole on every change. Malformed or missing JSON is treated as
    // empty rather than propagated: a renderer with a bad array is worse than
    // a renderer that briefly shows nothing.
    property var queue: []
    property var history: []

    // Written by the drawer, read here so the balloons know to stand down.
    property bool drawerOpen: false

    // Epoch milliseconds; 0 means not snoozing.
    property double snoozeUntil: 0

    // Read from the status registry's own file rather than from the Status
    // singleton. The renderers only read DND; referencing Status here would
    // instantiate it, and its onPresentationChanged writes status.dnd and
    // shells out to breaktimer.sh, side effects a read-only consumer must not
    // trigger. The file is the interface, so reading it directly is the same
    // value with none of the write side.
    property bool dnd: false

    function parseQueue() {
        try { root.queue = JSON.parse(queueFile.text() || "[]"); }
        catch (e) { root.queue = []; }
    }

    function parseHistory() {
        try { root.history = JSON.parse(historyFile.text() || "[]"); }
        catch (e) { root.history = []; }
    }

    // Mutations go through notifyctl, the only thing that talks to the daemon.
    function run(args) {
        ctl.command = ["notifyctl"].concat(args);
        ctl.running = false;
        ctl.running = true;
    }
    function close(id) { root.run(["close", String(id)]); }
    function closeAll() { root.run(["close-all"]); }

    // Inline images are local only. A notification is untrusted input, and any
    // remote <img src> (http(s), protocol-relative //host, ftp, data, or an
    // entity-encoded scheme) would make the shell fetch or embed something the
    // sender chose, which leaks that the notification was shown. Deny by
    // default: a tag survives only if every src it carries decodes to a file:
    // URL or a leading-slash absolute path. Every src assignment is checked,
    // not just the first, so a decoy attribute cannot shadow a remote one;
    // entities are decoded before the test, so an encoded scheme cannot slip
    // past.
    function sanitize(body) {
        function decode(s) {
            return s.replace(/&(?:#x([0-9a-f]+)|#(\d+)|(amp|colon|sol|tab|quot));/gi,
                function (m, hex, dec, name) {
                    if (hex !== undefined) return String.fromCharCode(parseInt(hex, 16));
                    if (dec !== undefined) return String.fromCharCode(parseInt(dec, 10));
                    return { amp: "&", colon: ":", sol: "/", tab: "\t", quot: "\"" }[name.toLowerCase()];
                });
        }
        return (body || "").replace(/<img\b[^>]*>/gi, function (tag) {
            const re = /(?:^|\s)src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
            let m, found = false, local = true;
            while ((m = re.exec(tag))) {
                found = true;
                const src = decode(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]);
                if (!(/^file:/i.test(src) || (src.charAt(0) === "/" && src.charAt(1) !== "/"))) local = false;
            }
            return (found && local) ? tag : "";
        });
    }

    function action(id, key) { root.run(["action", String(id), key]); }
    function clearHistory() { root.run(["clear-history"]); }

    // The rofi picker lives in the notifications component; a caller passes
    // the notification id and its [key, label] pairs. Only that component
    // uses this, but the singleton owns the Process so there is one place a
    // notifyctl-adjacent command is built.
    function actions(id, pairs) {
        const cmd = ["notify-actions.sh", String(id)];
        for (const pair of pairs) {
            cmd.push(pair[1]);
            cmd.push(pair[0]);
        }
        actProc.command = cmd;
        actProc.running = false;
        actProc.running = true;
    }

    Process { id: ctl }
    Process { id: actProc }

    FileView {
        id: queueFile
        path: root.dir + "/queue.json"
        watchChanges: true
        printErrors: false
        onFileChanged: reload()
        onLoaded: root.parseQueue()
        onLoadFailed: root.queue = []
    }

    FileView {
        id: historyFile
        path: root.dir + "/history.json"
        watchChanges: true
        printErrors: false
        onFileChanged: reload()
        onLoaded: root.parseHistory()
        onLoadFailed: root.history = []
    }

    FileView {
        id: drawerFile
        path: root.dir + "/drawer"
        watchChanges: true
        printErrors: false
        onFileChanged: reload()
        onLoaded: root.drawerOpen = drawerFile.text().trim() === "1"
        onLoadFailed: root.drawerOpen = false
    }

    FileView {
        id: dndFile
        path: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/status.dnd"
        watchChanges: true
        printErrors: false
        onFileChanged: reload()
        onLoaded: root.dnd = dndFile.text().trim() === "1"
        onLoadFailed: root.dnd = false
    }

    FileView {
        id: snoozeFile
        path: root.dir + "/snooze"
        watchChanges: true
        printErrors: false
        onFileChanged: reload()
        onLoaded: {
            const v = parseInt(snoozeFile.text().trim(), 10);
            root.snoozeUntil = isNaN(v) ? 0 : v * 1000;
        }
        onLoadFailed: root.snoozeUntil = 0
    }
}