The click action of a notification was a free-form shell string run through `bash -lc`, safe only when every sender shell-quoted every interpolated value perfectly. One slip is RCE: a hostile yt-dlp video title forged an output record and injected an mpv option into the click command (mehmetince.net RCE, partially addressed by #7847). Add a parameterized transport: omarchy-notification-send gains --exec-arg (repeatable), encoding a JSON argv into the omarchy-exec-argv hint. The shell runs it with Quickshell.execDetached(argv) and no shell, so data an attacker controls is only ever one argument and can never be reparsed as a command. The shell fails closed on a malformed argv hint. The legacy free-form --exec string is retained but honored only from Omarchy's own omarchy-action toasts, and deprecated. Migrate all in-repo callers (screenshot, screen recording, taildrop receive, migrate-notify, crash-watch, yt-dlp host) to --exec-arg. Update docs and tests.
574 lines
23 KiB
Bash
574 lines
23 KiB
Bash
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
|
|
run_node_test <<'JS'
|
|
const fs = require('fs')
|
|
const notifications = requireFromRoot('shell/plugins/notifications/NotificationLogic.js')
|
|
|
|
assert(notifications.isChromiumDerived('Brave Browser', ''), 'notifications detect chromium-derived apps by name')
|
|
assert(notifications.isChromiumDerived('', 'microsoft-edge'), 'notifications detect chromium-derived apps by icon')
|
|
assert(!notifications.isChromiumDerived('Slack', ''), 'notifications do not treat unrelated apps as chromium-derived')
|
|
|
|
assertEqual(
|
|
notifications.sanitizeBody('<img src="x">Hello', 'Slack', ''),
|
|
'Hello',
|
|
'notifications strip inline image tags'
|
|
)
|
|
|
|
assertEqual(
|
|
notifications.sanitizeBody('<a href="https://example.com">example.com</a> Message body', 'Chromium', ''),
|
|
'Message body',
|
|
'notifications strip chromium leading origin links'
|
|
)
|
|
|
|
assertEqual(
|
|
notifications.sanitizeBody('https://example.com/path Message body', 'Chromium', ''),
|
|
'Message body',
|
|
'notifications strip chromium leading origin text'
|
|
)
|
|
|
|
assertEqual(
|
|
notifications.sanitizeBody('https://example.com/path Message body', 'Slack', ''),
|
|
'https://example.com/path Message body',
|
|
'notifications keep non-browser leading origin text'
|
|
)
|
|
|
|
assert(notifications.summaryStartsWithGlyph(' Silenced'), 'notifications detect glyph-prefixed summaries')
|
|
assert(!notifications.summaryStartsWithGlyph('Normal summary'), 'notifications ignore normal summaries as glyph-prefixed')
|
|
assert(notifications.shouldRenderCompactGlyph('K', '', true), 'notifications render glyph-only single-line toasts compactly')
|
|
assert(!notifications.shouldRenderCompactGlyph('K', '', false), 'notifications give glyph hints with bodies the large icon slot')
|
|
assert(!notifications.shouldRenderCompactGlyph('K', 'file:///tmp/image.png', true), 'notifications keep image-backed glyph hints in the icon slot')
|
|
|
|
assert(notifications.shouldBypassDnd({ appName: 'omarchy-action', urgency: 1 }, 2), 'omarchy action toasts bypass DND')
|
|
assert(notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 2 }, 2), 'critical notify-send bypasses DND')
|
|
assert(!notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 1 }, 2), 'normal notify-send does not bypass DND')
|
|
assert(!notifications.shouldBypassDnd({ appName: 'Slack', urgency: 2 }, 2), 'critical app notifications do not bypass DND')
|
|
assert(!notifications.shouldBypassDnd({ appName: 'omarchy-menu-keybindings', urgency: 1 }, 2), 'omarchy command app names do not bypass DND')
|
|
assert(!notifications.isEphemeralApp('omarchy-menu-keybindings'), 'notifications treat omarchy command app names as normal apps')
|
|
|
|
// The click action's argv form: parsed from the persisted omarchy-exec-argv
|
|
// JSON only when it is a non-empty array of strings whose program is present
|
|
// and not a leading-dash option. Everything else fails closed so a malformed or
|
|
// hostile hint can never fall through to a shell.
|
|
assertDeepEqual(
|
|
notifications.parseExecArgv('["mpv","--","/home/me/a b.mp4"]'),
|
|
['mpv', '--', '/home/me/a b.mp4'],
|
|
'notifications parse a valid exec argv vector'
|
|
)
|
|
assertEqual(notifications.parseExecArgv(''), null, 'notifications reject an empty exec argv hint')
|
|
assertEqual(notifications.parseExecArgv('not json'), null, 'notifications reject a non-JSON exec argv hint')
|
|
assertEqual(notifications.parseExecArgv('"mpv"'), null, 'notifications reject an exec argv hint that is not an array')
|
|
assertEqual(notifications.parseExecArgv('[]'), null, 'notifications reject an empty exec argv array')
|
|
assertEqual(notifications.parseExecArgv('["mpv",5]'), null, 'notifications reject a non-string element in the exec argv')
|
|
assertEqual(notifications.parseExecArgv('["--include=x","y"]'), null, 'notifications reject a leading-dash program in the exec argv')
|
|
assertEqual(notifications.parseExecArgv('["",""]'), null, 'notifications reject an empty program in the exec argv')
|
|
|
|
// The argv vector rides on the snapshot as the raw JSON string, so the model's
|
|
// value comparison stays a plain string compare and the file round-trip is
|
|
// lossless.
|
|
const execSnapshot = notifications.snapshotOf({
|
|
id: 3,
|
|
appName: 'omarchy-action',
|
|
summary: 'Download complete',
|
|
hints: { 'omarchy-exec-argv': '["mpv","--","/tmp/clip.mp4"]' }
|
|
}, 1)
|
|
assertEqual(
|
|
execSnapshot.execArgv,
|
|
'["mpv","--","/tmp/clip.mp4"]',
|
|
'notifications carry the exec argv hint onto the snapshot'
|
|
)
|
|
|
|
assertDeepEqual(
|
|
notifications.popupPlacement('top', 32, 6),
|
|
{
|
|
anchors: { top: true, bottom: false, left: false, right: true },
|
|
margins: { top: 32, bottom: 6, left: 6, right: 6 }
|
|
},
|
|
'notifications clear a top bar while staying anchored top-right'
|
|
)
|
|
assertDeepEqual(
|
|
notifications.popupPlacement('right', 32, 6),
|
|
{
|
|
anchors: { top: true, bottom: false, left: false, right: true },
|
|
margins: { top: 6, bottom: 6, left: 6, right: 32 }
|
|
},
|
|
'notifications clear a right bar while staying anchored top-right'
|
|
)
|
|
assertDeepEqual(
|
|
notifications.popupPlacement('bottom', 32, 6),
|
|
{
|
|
anchors: { top: true, bottom: false, left: false, right: true },
|
|
margins: { top: 6, bottom: 6, left: 6, right: 6 }
|
|
},
|
|
'notifications ignore a bottom bar for popup placement'
|
|
)
|
|
assertDeepEqual(
|
|
notifications.popupPlacement('left', 32, 6),
|
|
{
|
|
anchors: { top: true, bottom: false, left: false, right: true },
|
|
margins: { top: 6, bottom: 6, left: 6, right: 6 }
|
|
},
|
|
'notifications ignore a left bar for popup placement'
|
|
)
|
|
|
|
const notification = {
|
|
id: 12,
|
|
appName: 'Mail',
|
|
appIcon: 'mail',
|
|
summary: 42,
|
|
body: 'Body',
|
|
image: 'file:///tmp/mail.png',
|
|
hints: { 'omarchy-glyph': '!' },
|
|
urgency: 1,
|
|
expireTimeout: 1.5
|
|
}
|
|
const snapshot = notifications.snapshotOf(notification, 12345)
|
|
assertDeepEqual(
|
|
{
|
|
id: snapshot.id,
|
|
originalId: snapshot.originalId,
|
|
app: snapshot.app,
|
|
appIcon: snapshot.appIcon,
|
|
summary: snapshot.summary,
|
|
body: snapshot.body,
|
|
image: snapshot.image,
|
|
glyph: snapshot.glyph,
|
|
urgency: snapshot.urgency,
|
|
expireTimeout: snapshot.expireTimeout,
|
|
timestamp: snapshot.timestamp
|
|
},
|
|
{
|
|
id: 12,
|
|
originalId: 12,
|
|
app: 'Mail',
|
|
appIcon: 'mail',
|
|
summary: '42',
|
|
body: 'Body',
|
|
image: 'file:///tmp/mail.png',
|
|
glyph: '!',
|
|
urgency: 1,
|
|
expireTimeout: 1.5,
|
|
timestamp: 12345
|
|
},
|
|
'notifications create stable snapshots'
|
|
)
|
|
|
|
// An in-place update keeps the popup's identity — the file name it was
|
|
// persisted under — and takes everything the card draws from the new content.
|
|
const replacement = notifications.replacementSnapshot(
|
|
{
|
|
id: 12,
|
|
appName: 'Slack',
|
|
summary: 'Thread v2',
|
|
body: 'message 2',
|
|
image: 'file:///tmp/new.png',
|
|
hints: { 'omarchy-glyph': '!' },
|
|
urgency: 2,
|
|
expireTimeout: 4000
|
|
},
|
|
12,
|
|
12345
|
|
)
|
|
assertDeepEqual(
|
|
{
|
|
id: replacement.id,
|
|
originalId: replacement.originalId,
|
|
timestamp: replacement.timestamp,
|
|
summary: replacement.summary,
|
|
body: replacement.body,
|
|
image: replacement.image,
|
|
glyph: replacement.glyph,
|
|
urgency: replacement.urgency,
|
|
expireTimeout: replacement.expireTimeout
|
|
},
|
|
{
|
|
id: 12,
|
|
originalId: 12,
|
|
timestamp: 12345,
|
|
summary: 'Thread v2',
|
|
body: 'message 2',
|
|
image: 'file:///tmp/new.png',
|
|
glyph: '!',
|
|
urgency: 2,
|
|
expireTimeout: 4000
|
|
},
|
|
'notifications take updated content without moving the popup it replaces'
|
|
)
|
|
assertEqual(
|
|
notifications.popupFileName(replacement),
|
|
'12345-12.json',
|
|
'notifications keep the persisted file name across an in-place update'
|
|
)
|
|
assert(
|
|
!notifications.popupRowChanged(replacement, replacement),
|
|
'notifications skip a refresh that matches the row it would write'
|
|
)
|
|
assert(
|
|
notifications.popupRowChanged(replacement, Object.assign({}, replacement, { body: 'message 3' })),
|
|
'notifications refresh a row whose content moved on'
|
|
)
|
|
assert(
|
|
!notifications.popupRowChanged(replacement, Object.assign({}, replacement, { timestamp: 999 })),
|
|
'notifications ignore identity fields when deciding whether a refresh has work'
|
|
)
|
|
|
|
const settings = notifications.parseSettings(JSON.stringify({ version: 3, dnd: true }))
|
|
assertEqual(settings.dnd, true, 'notifications parse the persisted DND state')
|
|
assertEqual(settings.legacy, false, 'notifications do not flag a current settings file as legacy')
|
|
assertEqual(notifications.parseSettings('').dnd, null, 'notifications leave DND unset without a settings file')
|
|
assertEqual(
|
|
notifications.parseSettings(JSON.stringify({ dnd: false, pending: [], past: [] })).legacy,
|
|
true,
|
|
'notifications flag a settings file still carrying the retired history rows'
|
|
)
|
|
assert(notifications.parseSettings('{').error, 'notifications flag invalid settings JSON')
|
|
|
|
// History is the notification files moved into the history dir, read back
|
|
// exactly like live popup files.
|
|
const archived = [
|
|
notifications.serializePopup({ id: 1, originalId: 1, summary: 'oldest', timestamp: 100 }, 1),
|
|
notifications.serializePopup({ id: 2, originalId: 2, summary: 'newest', timestamp: 900, expireTimeout: 30000, deadline: 5000 }, 1),
|
|
notifications.serializePopup({ id: 3, originalId: 3, summary: 'middle', timestamp: 500 }, 1)
|
|
].join('\n')
|
|
|
|
const historyReplay = notifications.historyRows(archived, [], 1, 2)
|
|
assertDeepEqual(
|
|
historyReplay.map(row => row.summary),
|
|
['newest', 'middle'],
|
|
'notifications replay the newest history rows up to the limit'
|
|
)
|
|
assertEqual(historyReplay[0].expireTimeout, 0, 'notifications replay history rows with the standard toast lifetime')
|
|
assertEqual('deadline' in historyReplay[0], false, 'notifications drop the restore deadline from replayed history rows')
|
|
assertDeepEqual(notifications.historyRows('', [], 1, 10), [], 'notifications replay nothing from an empty history dir')
|
|
|
|
// A toast still on screen is the newest notification there is, and its move
|
|
// into the history dir races the read, so the replay takes it from memory.
|
|
assertDeepEqual(
|
|
notifications.historyRows(archived, [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10)
|
|
.map(row => row.summary),
|
|
['on screen', 'newest', 'middle', 'oldest'],
|
|
'notifications replay the toasts still on screen alongside the archived ones'
|
|
)
|
|
assertDeepEqual(
|
|
notifications.historyRows(archived, [{ id: 2, originalId: 2, summary: 'newest', timestamp: 900 }], 1, 10)
|
|
.map(row => row.summary),
|
|
['newest', 'middle', 'oldest'],
|
|
'notifications replay a toast once when its archived file already landed'
|
|
)
|
|
assertDeepEqual(
|
|
notifications.historyRows('', [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10)
|
|
.map(row => row.summary),
|
|
['on screen'],
|
|
'notifications replay an on-screen toast even when nothing is archived yet'
|
|
)
|
|
|
|
const popup = {
|
|
id: 7,
|
|
originalId: 7,
|
|
app: 'Mail',
|
|
appIcon: 'mail',
|
|
summary: 'New message',
|
|
body: 'Body',
|
|
image: '',
|
|
glyph: '',
|
|
urgency: 2,
|
|
expireTimeout: 2500,
|
|
timestamp: 1000
|
|
}
|
|
assertEqual(notifications.popupFileName(popup), '1000-7.json', 'notifications name popup files by timestamp and id')
|
|
assertEqual(
|
|
notifications.serializePopup(popup, 1).indexOf('\n'),
|
|
-1,
|
|
'notifications serialize popups to a single line'
|
|
)
|
|
assertEqual(
|
|
notifications.popupEntry({ id: 1, timestamp: 5 }, 1).urgency,
|
|
1,
|
|
'notifications default popup urgency to normal'
|
|
)
|
|
assertEqual(
|
|
notifications.popupEntry({ id: 1, timestamp: 5, expireTimeout: 4000 }, 1).expireTimeout,
|
|
4000,
|
|
'notifications preserve popup expire timeouts unlike history rows'
|
|
)
|
|
|
|
// Persisted entries must not reference images another process owns: Chromium
|
|
// web apps (WhatsApp avatars included) delete their scoped /tmp files when
|
|
// the notification closes, and image:// URLs die with the live object.
|
|
assertEqual(
|
|
notifications.localImageFile('file:///tmp/scoped_dir/logo%20a.png'),
|
|
'/tmp/scoped_dir/logo a.png',
|
|
'notifications resolve file URLs to copyable paths'
|
|
)
|
|
assertEqual(notifications.localImageFile('/tmp/avatar.png'), '/tmp/avatar.png', 'notifications treat absolute paths as copyable')
|
|
assertEqual(notifications.localImageFile('mail'), '', 'notifications leave themed icon names uncopied')
|
|
assertEqual(notifications.localImageFile('image://notifs/1'), '', 'notifications cannot copy in-process image URLs')
|
|
|
|
const persistable = notifications.persistablePopup(
|
|
{ id: 9, originalId: 9, timestamp: 2000, appIcon: 'file:///tmp/scoped/logo.png', image: 'image://notifs/9', summary: 'Hi' },
|
|
'/state/images/'
|
|
)
|
|
assertDeepEqual(
|
|
persistable.copies,
|
|
[{ from: '/tmp/scoped/logo.png', to: '/state/images/2000-9-appIcon' }],
|
|
'notifications copy file-backed images into the state dir when persisting'
|
|
)
|
|
assertEqual(
|
|
persistable.entry.appIcon,
|
|
'file:///state/images/2000-9-appIcon',
|
|
'notifications persist the image copy instead of the sender-owned original'
|
|
)
|
|
assertEqual(persistable.entry.image, '', 'notifications drop dead in-process image URLs from persisted entries')
|
|
assertEqual(persistable.entry.summary, 'Hi', 'notifications leave the rest of the persisted entry untouched')
|
|
|
|
const repersisted = notifications.persistablePopup(persistable.entry, '/state/images/')
|
|
assertDeepEqual(repersisted.copies, [], 'notifications do not re-copy an entry already pointing at its copies')
|
|
assertEqual(
|
|
repersisted.entry.appIcon,
|
|
'file:///state/images/2000-9-appIcon',
|
|
'notifications keep a restored entry pointing at its existing copy'
|
|
)
|
|
|
|
assertEqual(
|
|
notifications.persistablePopup({ id: 9, originalId: 9, timestamp: 2000, appIcon: 'mail', image: '' }, '/state/images/').copies.length,
|
|
0,
|
|
'notifications leave themed icons alone when persisting'
|
|
)
|
|
assertEqual(
|
|
notifications.imageStem({ originalId: 9, timestamp: 2000 }) + '.json',
|
|
notifications.popupFileName({ originalId: 9, timestamp: 2000 }),
|
|
'notifications name image copies by the stem of the entry file they belong to'
|
|
)
|
|
|
|
const popupFiles = notifications.parsePopupFiles(
|
|
[
|
|
notifications.serializePopup({ id: 1, originalId: 1, summary: 'old-generation', urgency: 2, timestamp: 100 }, 1),
|
|
notifications.serializePopup({ id: 1, originalId: 1, summary: 'new-generation', urgency: 1, timestamp: 300 }, 1),
|
|
notifications.serializePopup({ id: 2, originalId: 2, summary: 'critical', urgency: 2, timestamp: 200 }, 1),
|
|
'{ torn write'
|
|
].join('\n'),
|
|
1
|
|
)
|
|
assertDeepEqual(
|
|
popupFiles.map(row => row.summary),
|
|
['new-generation', 'critical', 'old-generation'],
|
|
'notifications restore every persisted popup newest-first, never deduping ids across server generations'
|
|
)
|
|
assertDeepEqual(
|
|
notifications.parsePopupFiles('', 1),
|
|
[],
|
|
'notifications restore nothing from an empty popup dir'
|
|
)
|
|
|
|
assert(!notifications.popupExpired({ timestamp: 0 }, 0, 999999), 'critical popups never expire on restore')
|
|
assert(!notifications.popupExpired({ timestamp: 1000 }, 8000, 5000), 'popups within their lifetime are restored')
|
|
assert(notifications.popupExpired({ timestamp: 1000 }, 8000, 9000), 'popups past their lifetime are not restored')
|
|
assert(
|
|
!notifications.popupExpired({ timestamp: 1000, deadline: 20000 }, 8000, 15000),
|
|
'a restore-reset deadline outranks the original popup timestamp'
|
|
)
|
|
assert(
|
|
notifications.popupExpired({ timestamp: 1000, deadline: 20000 }, 8000, 20000),
|
|
'popups past their reset deadline are not restored'
|
|
)
|
|
assertEqual(
|
|
notifications.popupEntry(JSON.parse(notifications.serializePopup({ id: 1, originalId: 1, timestamp: 5, deadline: 9000 }, 1)), 1).deadline,
|
|
9000,
|
|
'notifications round-trip reset deadlines through popup files'
|
|
)
|
|
assertEqual(
|
|
'deadline' in notifications.popupEntry({ id: 1, timestamp: 5 }, 1),
|
|
false,
|
|
'notifications omit the deadline field until a restore sets it'
|
|
)
|
|
|
|
// A click action carried as a command is the only kind that survives a shell
|
|
// restart: a libnotify action leaves its sender waiting on an id from a server
|
|
// generation that no longer exists.
|
|
assertEqual(
|
|
notifications.snapshotOf({ id: 3, hints: { 'omarchy-exec': 'omarchy-menu-keybindings' } }, 1).exec,
|
|
'omarchy-menu-keybindings',
|
|
'notifications capture the click command from the exec hint'
|
|
)
|
|
assertEqual(
|
|
notifications.snapshotOf({ id: 3, hints: { 'omarchy-glyph': '!' } }, 1).exec,
|
|
'',
|
|
'notifications leave the click command empty without an exec hint'
|
|
)
|
|
assertEqual(
|
|
notifications.popupEntry(
|
|
JSON.parse(notifications.serializePopup({ id: 1, originalId: 1, timestamp: 5, exec: "mpv '/tmp/a b.mp4'" }, 1)),
|
|
1
|
|
).exec,
|
|
"mpv '/tmp/a b.mp4'",
|
|
'notifications round-trip the click command through popup files'
|
|
)
|
|
assertEqual(
|
|
notifications.popupEntry({ id: 1, originalId: 1, timestamp: 5 }, 1).exec,
|
|
'',
|
|
'notifications restore an empty click command for popups without one'
|
|
)
|
|
assertEqual(
|
|
notifications.historyEntry({ id: 1, exec: 'xdg-open /tmp/received' }, 1).exec,
|
|
'xdg-open /tmp/received',
|
|
'notifications keep the click command on history rows'
|
|
)
|
|
|
|
const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8')
|
|
assert(
|
|
/readonly property int historyLimit: 10/.test(serviceQml),
|
|
'notifications service keeps the last ten notifications in history'
|
|
)
|
|
assert(
|
|
/function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml),
|
|
'notifications history IPC replays recent notifications'
|
|
)
|
|
assert(
|
|
/readonly property string popupStateDir: stateDir \+ "notifications\/"/.test(serviceQml),
|
|
'notifications service persists popups under the omarchy state dir'
|
|
)
|
|
assert(
|
|
/readonly property string historyDir: popupStateDir \+ "history\/"/.test(serviceQml),
|
|
'notifications service keeps history in a subdirectory of the popup state dir'
|
|
)
|
|
assert(
|
|
/if \(entry\) \{\s*\n\s*archivePopupFileFor\(entry\)[\s\S]{0,200}?popupModel\.remove\(index\)/.test(serviceQml),
|
|
'notifications service archives the popup file when a popup leaves the screen'
|
|
)
|
|
assert(
|
|
/mv -f \\"\$4\/\$3\\" \\"\$1\/\$3\\"/.test(serviceQml),
|
|
'notifications service archives by moving the popup file into the history dir'
|
|
)
|
|
assert(
|
|
/head -n \\"-\$limit\\"/.test(serviceQml),
|
|
'notifications service trims history to the newest entries in the same job'
|
|
)
|
|
assert(
|
|
/\\"\$imgs\/\$\{stale%\.json\}\\"-\*/.test(serviceQml),
|
|
'notifications service drops a trimmed history entry\'s image copies with it'
|
|
)
|
|
assert(
|
|
/readonly property string imagesDir: popupStateDir \+ "images\/"/.test(serviceQml),
|
|
'notifications service keeps image copies beside the popup and history files'
|
|
)
|
|
assert(
|
|
/copyImagesScript \+\n\s*"printf/.test(serviceQml),
|
|
'notifications service copies images before writing the JSON that references them'
|
|
)
|
|
assert(
|
|
/timeout 5 head -c 5242881 -- \\"\$1\\" > \\"\$2\.tmp\\"[\s\S]{0,120}?mv -f -- \\"\$2\.tmp\\" \\"\$2\\"/.test(serviceQml),
|
|
'notifications service bounds image copies through a validated temp file'
|
|
)
|
|
assert(
|
|
/rm -f \\"\$1\/\$2\.json\\" \\"\$3\/\$2\\"-\*/.test(serviceQml),
|
|
'notifications service deletes a superseded popup\'s image copies with its file'
|
|
)
|
|
assert(
|
|
/if \(!isEphemeral\(notification\)\) \{\s*\n\s*writeSilenced\(notification, snapshot\)/.test(serviceQml),
|
|
'notifications service records DND-silenced notifications straight into history'
|
|
)
|
|
assert(
|
|
/function releaseSilenced\(notification, originalId\)[\s\S]{0,300}?notification\.tracked = false/.test(serviceQml),
|
|
'notifications service holds a silenced notification until its history write has run'
|
|
)
|
|
assert(
|
|
/if \(updated && NotificationLogic\.popupRowChanged\(written, updated\)\) \{\s*\n\s*service\.writeSilenced\(notification, updated\)/.test(serviceQml),
|
|
'notifications service re-persists a silenced notification updated while its write was queued'
|
|
)
|
|
assert(
|
|
/rows\.push\(NotificationLogic\.persistablePopup\(\{[\s\S]{0,400}?\}, imagesDir\)\.entry\)/.test(serviceQml),
|
|
'notifications service replays carried-over toasts from their persisted image copies'
|
|
)
|
|
assert(
|
|
/function sweepOrphanImages\(\)[\s\S]{0,400}?\|\| rm -f \\"\$img\\"/.test(serviceQml),
|
|
'notifications service sweeps image copies whose JSON never landed'
|
|
)
|
|
assert(
|
|
/service\.replayCarryOver = liveRowsForReplay\(\)/.test(serviceQml),
|
|
'notifications service carries the toasts still on screen into the replay'
|
|
)
|
|
assert(
|
|
/watchForUpdates\(notification, snapshot\)/.test(serviceQml),
|
|
'notifications service watches a shown notification for in-place updates'
|
|
)
|
|
assert(
|
|
/if \(signal && typeof signal\.connect === "function"\) signal\.connect\(refresh\)/.test(serviceQml),
|
|
'notifications service refreshes the popup from every property the card draws'
|
|
)
|
|
assert(
|
|
/popupModel\.setProperty\(i, roles\[r\], updated\[roles\[r\]\]\)[\s\S]{0,600}?persistPopupFile\(updated\)/.test(serviceQml),
|
|
'notifications service rewrites both the row and its file when a notification is updated in place'
|
|
)
|
|
assert(
|
|
/if \(!NotificationLogic\.popupRowChanged\(row, updated\)\) return/.test(serviceQml),
|
|
'notifications service leaves the row and its file alone when a refresh finds nothing changed'
|
|
)
|
|
assert(
|
|
/popupModel\.insert\(0, snapshot\)[\s\S]{0,300}?service\.refreshPopup\(notification, snapshot\.originalId, snapshot\.timestamp\)/.test(serviceQml),
|
|
'notifications service catches up on an update that beat the deferred row insert'
|
|
)
|
|
assert(
|
|
/function showRecentHistory\(\)[\s\S]{0,300}?enqueueHistoryRead\(\)/.test(serviceQml),
|
|
'notifications service reads history from its place in the file queue'
|
|
)
|
|
assert(
|
|
/if \(job\.read\) \{\s*\n\s*startHistoryRead\(\)/.test(serviceQml),
|
|
'notifications service runs the queued read when its turn comes'
|
|
)
|
|
assert(
|
|
/function runNextPopupFileJob\(\) \{\s*\n\s*if \(readHistoryProc\.running \|\| popupFileProc\.running\) return/.test(serviceQml),
|
|
'notifications service holds queued file work until a history read finishes'
|
|
)
|
|
assert(
|
|
/id: readHistoryProc[\s\S]{0,300}?onExited: service\.runNextPopupFileJob\(\)/.test(serviceQml),
|
|
'notifications service releases the file queue even when a history read comes back empty'
|
|
)
|
|
assert(
|
|
/onSummaryChanged: cardSlot\.remainingLifetime = 1\.0/.test(serviceQml),
|
|
'notifications service restarts the countdown when a toast is updated under it'
|
|
)
|
|
assert(
|
|
/awk 1 \\"\$1\\"\/\*\.json 2>\/dev\/null \|\| true", "--", historyDir/.test(serviceQml),
|
|
'notifications service replays history by reading the archived files'
|
|
)
|
|
assert(
|
|
/restorePopupsProc\.running = true/.test(serviceQml),
|
|
'notifications service restores persisted popups on startup'
|
|
)
|
|
assert(
|
|
/if \(isRestoredRow\(row\)\) continue/.test(serviceQml),
|
|
'notifications service protects restored popups from new-generation id collisions'
|
|
)
|
|
assert(
|
|
/var ref = !restored && originalId >= 0 \? liveRefs\[originalId\] : null/.test(serviceQml),
|
|
'notifications service never resolves a restored popup to a live server object'
|
|
)
|
|
assert(
|
|
/service\.restoredPopups\[NotificationLogic\.popupFileName\(rows\[i\]\)\] = true/.test(serviceQml),
|
|
'notifications service treats replayed history rows as restored, never as live notifications'
|
|
)
|
|
assert(
|
|
/popupFileName\(row\) !== keepFileName/.test(serviceQml),
|
|
'notifications service keeps a same-millisecond replacement popup file'
|
|
)
|
|
assert(
|
|
/awk 1 \\"\$1\\"\/\*\.json/.test(serviceQml),
|
|
'notifications service delimits every popup file during restore'
|
|
)
|
|
assert(
|
|
/var command = entry \? String\(entry\.exec \|\| ""\) : ""[\s\S]{0,300}?Util\.execDetached\(command\)/.test(serviceQml),
|
|
'notifications service runs the popup click command itself instead of a libnotify action'
|
|
)
|
|
assert(
|
|
/function clear\(\): string \{\s*service\.clearHistory\(\)/.test(serviceQml),
|
|
'notifications clear IPC forgets the recorded history'
|
|
)
|
|
assert(
|
|
!/pendingModel|pastModel/.test(serviceQml),
|
|
'notifications service keeps no in-memory history models'
|
|
)
|
|
JS
|