Repository navigation
Expand file tree
/
Copy pathReprieveModel.js
More file actions
932 lines (871 loc) · 32.8 KB
/
Copy pathReprieveModel.js
File metadata and controls
932 lines (871 loc) · 32.8 KB
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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
// Reprieve — pure undo/redo model for compositor window close.
// QML imports this; Node tests require() it. No I/O, no Hyprland.
//
// Everything here treats window metadata (address, class, title, workspace,
// pid) as untrusted input. Anything that ends up inside a Hyprland dispatch
// string is validated by normalizeAddress()/normalizeWorkspace() first.
var PLUGIN_ID = "tech.greyforge.reprieve"
var PARK_WORKSPACE = "special:reprieve"
var DEFAULT_MAX = 10
var MIN_MAX = 1
var MAX_MAX = 20
var DEFAULT_PARK_TIMEOUT = 0
var MIN_PARK_TIMEOUT = 5
var MAX_PARK_TIMEOUT = 120
var JOURNAL_SCHEMA = 1
var JOURNAL_MAX_BYTES = 262144
var JOURNAL_MAX_ENTRIES = 64
var LABEL_MAX = 52
var CLASS_MAX = 128
var ADDRESS_RE = /^0x[0-9a-f]{1,16}$/
var WORKSPACE_RE = /^[A-Za-z0-9_.:+-]{1,64}$/
function defaultExcludedClasses() {
return {
"org.omarchy.screensaver": true,
"org.omarchy.lock": true,
"org.omarchy.polkit": true,
"hyprland-share-picker": true,
"xdg-desktop-portal-hyprland": true
}
}
function clampMax(value) {
var n = Number(value)
if (!isFinite(n)) return DEFAULT_MAX
n = Math.floor(n)
if (n < MIN_MAX) return MIN_MAX
if (n > MAX_MAX) return MAX_MAX
return n
}
// Park timeout in seconds. 0 means off (the default). Any positive value
// below the minimum clamps up to the minimum; anything above the maximum
// clamps down. Non-numeric input disables the timeout rather than guessing.
function clampParkTimeout(value) {
var n = Number(value)
if (!isFinite(n)) return DEFAULT_PARK_TIMEOUT
n = Math.floor(n)
if (n <= 0) return DEFAULT_PARK_TIMEOUT
if (n < MIN_PARK_TIMEOUT) return MIN_PARK_TIMEOUT
if (n > MAX_PARK_TIMEOUT) return MAX_PARK_TIMEOUT
return n
}
function nowMs(value) {
var n = Number(value)
if (isFinite(n) && n > 0) return Math.floor(n)
if (typeof Date !== "undefined" && Date.now) return Date.now()
return 0
}
// Flight: the park/restore effect drawn by the overlay. "subtle" glides a
// snapshot of the window into the bar mark and back; "angel" adds the winged
// light that swoops in to carry it; "off" is a plain cut. Unknown input
// falls back to the default rather than guessing.
var FLIGHT_MODES = ["off", "subtle", "angel"]
var DEFAULT_FLIGHT = "subtle"
function normalizeFlight(value) {
var v = String(value === undefined || value === null ? "" : value).trim().toLowerCase()
return FLIGHT_MODES.indexOf(v) === -1 ? DEFAULT_FLIGHT : v
}
// Parked-at stamps, floored to whole milliseconds. Entries without a
// positive stamp predate the timeout feature and are exempt from expiry.
function stampMs(value) {
return Math.floor(Number(value) || 0)
}
function hasStamp(value) {
return stampMs(value) > 0
}
function createState(opts) {
opts = opts || {}
return {
undo: [],
redo: [],
max: clampMax(opts.max),
parkTimeout: clampParkTimeout(opts.parkTimeout),
excluded: opts.excluded || defaultExcludedClasses(),
parkWorkspace: opts.parkWorkspace || PARK_WORKSPACE,
sequence: Math.max(0, Math.floor(Number(opts.sequence) || 0))
}
}
function cloneState(state) {
state = state || {}
return {
undo: (state.undo || []).slice(),
redo: (state.redo || []).slice(),
max: clampMax(state.max),
parkTimeout: clampParkTimeout(state.parkTimeout),
excluded: state.excluded || defaultExcludedClasses(),
parkWorkspace: state.parkWorkspace || PARK_WORKSPACE,
sequence: Math.max(0, Math.floor(Number(state.sequence) || 0))
}
}
// Hyprland addresses are lowercase hex. Anything else is rejected outright
// so a hostile value can never reach a dispatch string.
function normalizeAddress(value) {
var text = String(value == null ? "" : value).trim().toLowerCase()
if (!text) return ""
if (text.slice(0, 2) === "0x") text = text.slice(2)
text = text.replace(/^0+(?=[0-9a-f])/, "")
if (!text) return ""
var out = "0x" + text
return ADDRESS_RE.test(out) ? out : ""
}
function normalizeWorkspace(value) {
var text = String(value == null ? "" : value).trim()
if (!text) return ""
return WORKSPACE_RE.test(text) ? text : ""
}
function isSpecialWorkspace(name) {
return String(name || "").indexOf("special:") === 0
}
function clampInt(value, lo, hi, fallback) {
var n = Number(value)
if (!isFinite(n)) return fallback
n = Math.floor(n)
if (n < lo || n > hi) return fallback
return n
}
function sanitizeLabel(value, maxLen) {
var title = String(value == null ? "" : value)
var out = ""
for (var i = 0; i < title.length && out.length < maxLen; i++) {
var code = title.charCodeAt(i)
if (code < 32 || code === 127) continue
out += title.charAt(i)
}
out = out.replace(/\s+/g, " ").trim()
return out || "window"
}
function sanitizeClass(value) {
var text = String(value == null ? "" : value)
var out = ""
for (var i = 0; i < text.length && out.length < CLASS_MAX; i++) {
var code = text.charCodeAt(i)
if (code < 33 || code === 127) continue
out += text.charAt(i)
}
return out
}
function shortLabel(snapshot) {
return sanitizeLabel((snapshot && (snapshot.title || snapshot.class)) || "window", LABEL_MAX)
}
function toastLabel(snapshot) {
return sanitizeLabel((snapshot && (snapshot.label || snapshot.title || snapshot.class)) || "window", 32)
}
function canPark(snapshot, state) {
if (!snapshot) return false
if (!normalizeAddress(snapshot.address)) return false
var klass = sanitizeClass(snapshot.class)
if (!klass) return false
var excluded = (state && state.excluded) || defaultExcludedClasses()
if (excluded[klass]) return false
var workspace = String(snapshot.workspace || "")
var park = (state && state.parkWorkspace) || PARK_WORKSPACE
if (workspace === park) return false
if (isSpecialWorkspace(workspace)) return false
return true
}
function canRelaunch(snapshot, state) {
if (!snapshot) return false
var klass = sanitizeClass(snapshot.class)
if (!klass) return false
var excluded = (state && state.excluded) || defaultExcludedClasses()
if (excluded[klass]) return false
if (isSpecialWorkspace(snapshot.workspace)) return false
return !!relaunchCommand(snapshot)
}
function nextSequence(state) {
return Math.max(0, Math.floor(Number(state.sequence) || 0)) + 1
}
function parkAction(snapshot, sequence, parkedAt) {
var klass = sanitizeClass(snapshot.class)
return {
type: "park",
address: normalizeAddress(snapshot.address),
workspace: normalizeWorkspace(snapshot.workspace),
floating: !!snapshot.floating,
fullscreen: clampInt(snapshot.fullscreen, 0, 2, 0),
fullscreenClient: clampInt(snapshot.fullscreenClient, 0, 2, 0),
class: klass,
title: sanitizeLabel(snapshot.title || klass || "window", LABEL_MAX),
pid: clampInt(snapshot.pid, 0, 4194304, 0),
media: sanitizeMedia(snapshot.media),
label: shortLabel({ title: snapshot.title, class: klass }),
sequence: sequence,
parkedAt: nowMs(snapshot.parkedAt != null ? snapshot.parkedAt : parkedAt),
recovered: snapshot.recovered === true
}
}
function relaunchAction(snapshot, sequence) {
var klass = sanitizeClass(snapshot.class)
// Reopen entries relaunch the app (browser / PWA) rather than moving a
// live window, so a Hyprland address is meaningless here. The window is
// already gone and its address may later be reused by an unrelated
// window; storing it would let Panel resolve restores against the wrong
// entry and risks confusion with live parked windows. Keep Reopen
// address-less like converted dead windows (markDead / reconcile).
return {
type: "relaunch",
address: "",
workspace: normalizeWorkspace(snapshot.workspace),
class: klass,
title: sanitizeLabel(snapshot.title || klass || "window", LABEL_MAX),
label: shortLabel({ title: snapshot.title, class: klass }),
sequence: sequence
}
}
// Media restoration payload: MPRIS players successfully paused, plus legacy
// PipeWire mute records (pid-matched on resume) for upgrade cleanup. Bounded
// and re-typed so a journal cannot smuggle anything else through.
function sanitizeMedia(media) {
if (!media || typeof media !== "object") return null
var muted = []
var srcMuted = Array.isArray(media.muted) ? media.muted : []
for (var i = 0; i < srcMuted.length && muted.length < 32; i++) {
var item = srcMuted[i]
if (!item || typeof item !== "object") continue
var index = clampInt(item.index, 0, 2147483647, -1)
if (index < 0) continue
muted.push({ index: index, pid: clampInt(item.pid, 0, 4194304, 0) })
}
var paused = []
var srcPaused = Array.isArray(media.paused) ? media.paused : []
for (var j = 0; j < srcPaused.length && paused.length < 16; j++) {
var name = String(srcPaused[j] == null ? "" : srcPaused[j])
if (!/^org\.mpris\.MediaPlayer2\.[A-Za-z0-9_.-]{1,180}$/.test(name)) continue
paused.push(name)
}
if (!muted.length && !paused.length) return null
return { muted: muted, paused: paused }
}
function capUndo(undo, max, kills) {
var next = undo.slice()
while (next.length > max) {
var dropped = next.shift()
if (dropped && dropped.type === "park" && dropped.address)
kills.push(dropped.address)
}
return next
}
function pushPark(state, snapshot, nowOverride) {
if (!canPark(snapshot, state)) {
return { state: state, kills: [], action: null, reason: "excluded" }
}
var next = cloneState(state)
var addr = normalizeAddress(snapshot.address)
// Re-parking an address already tracked replaces the stale entry.
next.undo = next.undo.filter(function (a) { return !(a && a.address === addr) })
next.redo = next.redo.filter(function (a) { return !(a && a.address === addr) })
next.sequence = nextSequence(next)
var action = parkAction(snapshot, next.sequence, nowOverride)
next.undo = next.undo.concat([action])
next.redo = []
var kills = []
next.undo = capUndo(next.undo, next.max, kills)
return { state: next, kills: kills, action: action, reason: "parked" }
}
// Adopt a live window that is already sitting on the park workspace but
// that nobody remembers (stranded after a crash, moved there by hand, or an
// overflow close the app refused). Never closes anything.
function pushRecovered(state, snapshot, nowOverride) {
var addr = normalizeAddress(snapshot && snapshot.address)
if (!addr || !state) return { state: state, action: null, reason: "invalid" }
if (findParked(state, addr) !== -1) return { state: state, action: null, reason: "tracked" }
var klass = sanitizeClass(snapshot.class) || "window"
var excluded = (state && state.excluded) || defaultExcludedClasses()
if (excluded[klass]) return { state: state, action: null, reason: "excluded" }
var next = cloneState(state)
next.sequence = nextSequence(next)
var ws = normalizeWorkspace(snapshot.workspace)
if (isSpecialWorkspace(ws)) ws = ""
var action = parkAction({
address: addr,
workspace: ws,
class: klass,
title: snapshot.title || "",
pid: snapshot.pid || 0,
floating: !!snapshot.floating,
fullscreen: 0,
fullscreenClient: 0,
recovered: true
}, next.sequence, nowOverride)
next.undo = next.undo.concat([action])
return { state: next, action: action, reason: "recovered" }
}
function pushRelaunch(state, snapshot) {
if (!canRelaunch(snapshot, state)) {
return { state: state, kills: [], action: null, reason: "skip" }
}
var next = cloneState(state)
next.sequence = nextSequence(next)
var action = relaunchAction(snapshot, next.sequence)
next.undo = next.undo.concat([action])
next.redo = []
var kills = []
next.undo = capUndo(next.undo, next.max, kills)
return { state: next, kills: kills, action: action, reason: "recorded" }
}
function undo(state) {
if (!state || !state.undo || state.undo.length === 0) {
return { state: state, effects: [], action: null }
}
return undoAt(state, state.undo.length - 1, null)
}
function undoAt(state, index, opts) {
opts = opts || {}
// Crafted IPC can pass NaN or fractions: floor and re-check so a bad
// index is a no-op instead of appending undefined to redo.
var at = Math.floor(Number(index))
if (!state || !state.undo || !isFinite(at) || at < 0 || at >= state.undo.length) {
return { state: state, effects: [], action: null }
}
var next = cloneState(state)
var action = next.undo[at]
next.undo = next.undo.slice(0, at).concat(next.undo.slice(at + 1))
next.redo = next.redo.concat([action])
var effects = undoEffects(action, next, opts.workspace)
return { state: next, effects: effects, action: action }
}
function redo(state, nowOverride) {
if (!state || !state.redo || state.redo.length === 0) {
return { state: state, effects: [], action: null }
}
var next = cloneState(state)
var action = next.redo[next.redo.length - 1]
next.redo = next.redo.slice(0, -1)
if (action && action.type === "park") {
// Redo re-hides the window, starting a new hidden interval: restart
// the clock instead of inheriting the stamp from the original park.
action = Object.assign({}, action, { parkedAt: nowMs(nowOverride) })
}
next.undo = next.undo.concat([action])
var kills = []
next.undo = capUndo(next.undo, next.max, kills)
var effects = redoEffects(action, next)
for (var i = 0; i < kills.length; i++)
effects.push({ type: "close", address: kills[i] })
return { state: next, effects: effects, action: action }
}
// `here` is the workspace to restore onto instead of the recorded one. A
// recovered window (no recorded workspace) always needs a fallback; the
// caller passes the current workspace for that.
function undoEffects(action, state, here) {
if (!action) return []
var hereWs = normalizeWorkspace(here)
if (hereWs && isSpecialWorkspace(hereWs)) hereWs = ""
if (action.type === "park") {
var recorded = normalizeWorkspace(action.workspace)
if (isSpecialWorkspace(recorded)) recorded = ""
var target = hereWs || recorded
return [
{
type: "restore",
address: action.address,
workspace: target,
here: !!hereWs && hereWs !== recorded,
floating: !!action.floating,
fullscreen: clampInt(action.fullscreen, 0, 2, 0),
fullscreenClient: clampInt(action.fullscreenClient, 0, 2, 0),
class: action.class || "",
media: action.media || null,
pid: Number(action.pid || 0)
}
]
}
if (action.type === "relaunch") {
return [{
type: "relaunch",
class: action.class || "",
workspace: hereWs || action.workspace || ""
}]
}
return []
}
function redoEffects(action, state) {
if (!action) return []
if (action.type === "park") {
return [{
type: "park",
address: action.address,
workspace: (state && state.parkWorkspace) || PARK_WORKSPACE,
class: action.class || "",
pid: Number(action.pid || 0),
label: action.label || ""
}]
}
// Relaunch redo would mean closing the window we just started. Skip;
// the user can Super+W that window if they want it gone again.
return []
}
function parkedAddresses(state) {
var out = []
var undo = (state && state.undo) || []
for (var i = 0; i < undo.length; i++) {
if (undo[i] && undo[i].type === "park" && undo[i].address)
out.push(undo[i].address)
}
return out
}
function findParked(state, address) {
var addr = normalizeAddress(address)
if (!addr) return -1
var undo = (state && state.undo) || []
for (var i = 0; i < undo.length; i++) {
if (undo[i] && undo[i].type === "park" && undo[i].address === addr) return i
}
return -1
}
// Stable identity for timeline rows. Park rows resolve by live address
// (sweep-safe); Reopen rows are address-less, so they resolve by sequence,
// which survives timeout sweeps that remove entries above them.
function findBySequence(state, sequence) {
var seq = Number(sequence)
if (!isFinite(seq)) return -1
var undo = (state && state.undo) || []
for (var i = 0; i < undo.length; i++) {
if (undo[i] && Number(undo[i].sequence) === seq) return i
}
return -1
}
// Forget a single timeline entry (used for Reopen rows, which have no live
// window to close). Park rows keep using closeParked/dropAddress so media
// cleanup still runs.
function removeBySequence(state, sequence) {
var seq = Number(sequence)
if (!isFinite(seq) || !state) return state
var next = cloneState(state)
next.undo = (next.undo || []).filter(function (a) { return !(a && Number(a.sequence) === seq) })
return next
}
function attachMedia(state, address, media) {
var addr = normalizeAddress(address)
if (!addr || !state) return state
var clean = sanitizeMedia(media)
var next = cloneState(state)
function withMedia(action) {
if (action && action.type === "park" && action.address === addr)
return Object.assign({}, action, { media: clean })
return action
}
next.undo = next.undo.map(withMedia)
next.redo = next.redo.map(withMedia)
return next
}
function dropAddress(state, address) {
var addr = normalizeAddress(address)
if (!addr) return state
var next = cloneState(state)
function keep(action) {
return !(action && action.type === "park" && action.address === addr)
}
next.undo = next.undo.filter(keep)
next.redo = next.redo.filter(keep)
return next
}
// Grace period for enabling the timeout: restamp every stamped undo entry
// to now so the full timeout runs from enable time instead of expiring
// windows on the spot for age accrued while the timeout was off. Entries
// without a stamp (pre-timeout history) stay exempt, redo entries describe
// visible windows and are never expirable, and redo() restamps on re-park.
function restampParked(state, nowValue) {
if (!state || !state.undo) return state
var now = nowMs(nowValue)
if (now <= 0) return state
var changed = false
var undo = state.undo.map(function (a) {
if (!a || a.type !== "park" || !hasStamp(a.parkedAt)) return a
changed = true
var copy = Object.assign({}, a)
copy.parkedAt = now
return copy
})
if (!changed) return state
var next = cloneState(state)
next.undo = undo
return next
}
// Expire parked windows older than timeoutSec. Only undo entries are
// eligible: redo entries describe visible windows waiting to be re-parked,
// and closing those would destroy windows the user is looking at.
// Entries without a parkedAt stamp (pre-timeout journals) are exempt so an
// upgrade can never mass-close history it cannot date. Returns
// { state, expired } where expired lists the removed park actions oldest
// first. A non-positive timeout disables expiry entirely.
function expireParked(state, nowValue, timeoutSec) {
var result = { state: state, expired: [] }
var timeout = clampParkTimeout(timeoutSec)
if (timeout <= 0 || !state || !state.undo) return result
var now = nowMs(nowValue)
if (now <= 0) return result
var deadline = timeout * 1000
var expiredAddrs = {}
var expired = []
for (var i = 0; i < state.undo.length; i++) {
var a = state.undo[i]
if (!a || a.type !== "park" || !a.address) continue
var stamped = stampMs(a.parkedAt)
if (stamped <= 0) continue
if (now - stamped >= deadline) {
expired.push(a)
expiredAddrs[a.address] = true
}
}
if (!expired.length) return result
var next = cloneState(state)
next.undo = next.undo.filter(function (a) {
return !(a && a.type === "park" && a.address && expiredAddrs[a.address])
})
next.redo = next.redo.filter(function (a) {
return !(a && a.type === "park" && a.address && expiredAddrs[a.address])
})
result.state = next
result.expired = expired
return result
}
// A parked window's process died. Keep the timeline entry as a safe Reopen
// when the class is allowlisted; otherwise the entry is gone for good.
function markDead(state, address) {
var addr = normalizeAddress(address)
var result = { state: state, converted: false, removed: false, action: null }
if (!addr || !state) return result
var next = cloneState(state)
function convert(action) {
if (!(action && action.type === "park" && action.address === addr)) return action
result.action = action
if (relaunchCommand(action)) {
result.converted = true
var re = relaunchAction(action, action.sequence)
re.address = ""
return re
}
result.removed = true
return null
}
next.undo = next.undo.map(convert).filter(function (a) { return !!a })
next.redo = next.redo.filter(function (a) {
// A redo entry for the same address describes the same dead window.
// Converted (undo won) keeps the Reopen; otherwise this entry is gone.
if (a && a.type === "park" && a.address === addr) { result.action = a; result.removed = !result.converted; return false }
return true
})
result.state = next
return result
}
// Narrow allowlist. Nothing here is derived from argv or the environment.
function relaunchCommand(snapshot) {
var klass = sanitizeClass(snapshot && snapshot.class)
if (klass === "google-chrome" || klass === "chromium" || klass === "brave-browser"
|| klass === "Brave-browser" || klass === "firefox" || klass === "firefox-esr") {
return ["omarchy-launch-browser"]
}
var pwa = klass.match(/^(?:chrome|chromium|brave|google-chrome)-([A-Za-z0-9._-]+)__-Default$/i)
if (pwa) {
var host = pwa[1].replace(/_/g, ".")
if (!/^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/.test(host)) return null
if (host.indexOf("..") !== -1) return null
return ["omarchy-launch-webapp", "https://" + host]
}
return null
}
// Restore every live parked window. Oldest first so the stack order the
// user built up is what they see reappear; each restored action lands on
// redo so it can be re-parked.
function restoreAll(state, currentWorkspace) {
if (!state) return { state: state, effects: [], actions: [] }
var next = cloneState(state)
var effects = []
var actions = []
var keep = []
for (var i = 0; i < next.undo.length; i++) {
var action = next.undo[i]
if (action && action.type === "park") {
actions.push(action)
var target = normalizeWorkspace(action.workspace)
var fx = undoEffects(action, next, target ? "" : currentWorkspace)
for (var j = 0; j < fx.length; j++) effects.push(fx[j])
} else if (action) {
keep.push(action)
}
}
next.undo = keep
next.redo = next.redo.concat(actions)
return { state: next, effects: effects, actions: actions }
}
// clear() never orphans live windows: it refuses outright while any parked
// window is tracked. Callers restore or close them first.
function clear(state) {
var live = parkedAddresses(state).length
if (live > 0) return { state: state, ok: false, live: live }
var next = cloneState(state)
next.undo = []
next.redo = []
return { state: next, ok: true, live: 0 }
}
// reset() = restore everything, then forget history. Settings survive.
function reset(state, currentWorkspace) {
var restored = restoreAll(state, currentWorkspace)
var next = createState({
max: state && state.max,
parkTimeout: state && state.parkTimeout,
excluded: state && state.excluded,
parkWorkspace: state && state.parkWorkspace,
sequence: state && state.sequence
})
return { state: next, effects: restored.effects, actions: restored.actions }
}
function statusSummary(state) {
var undo = (state && state.undo) || []
var redo = (state && state.redo) || []
var last = undo.length ? undo[undo.length - 1] : null
var recovered = 0
for (var i = 0; i < undo.length; i++)
if (undo[i] && undo[i].type === "park" && undo[i].recovered) recovered++
return {
undo: undo.length,
redo: redo.length,
last: last ? (last.label || last.class || last.type) : "",
parked: parkedAddresses(state).length,
recovered: recovered
}
}
// ---------------------------------------------------------------------------
// Recovery journal
//
// Only what is needed to find a parked window again after the shell
// restarts: address, where it came from, how it looked, and which process
// owned it. No argv, no environment, no titles.
function toJournal(state, session) {
var entries = []
var undo = (state && state.undo) || []
for (var i = 0; i < undo.length && entries.length < JOURNAL_MAX_ENTRIES; i++) {
var a = undo[i]
if (!a || a.type !== "park" || !a.address) continue
var entry = {
address: a.address,
workspace: normalizeWorkspace(a.workspace),
class: sanitizeClass(a.class),
floating: !!a.floating,
fullscreen: clampInt(a.fullscreen, 0, 2, 0),
fullscreenClient: clampInt(a.fullscreenClient, 0, 2, 0),
pid: clampInt(a.pid, 0, 4194304, 0),
sequence: clampInt(a.sequence, 0, 2147483647, 0)
}
var stamped = stampMs(a.parkedAt)
if (stamped > 0) entry.parkedAt = Math.min(stamped, 9007199254740991)
if (a.media) entry.media = sanitizeMedia(a.media)
if (a.recovered) entry.recovered = true
entries.push(entry)
}
return {
schema: JOURNAL_SCHEMA,
session: String(session || ""),
sequence: clampInt(state && state.sequence, 0, 2147483647, 0),
entries: entries
}
}
// Returns { status, entries, sequence }. status is one of:
// "empty" — nothing persisted
// "invalid" — malformed; caller should quarantine
// "stale" — valid but from another compositor session
// "ok" — usable entries (already validated and re-typed)
function parseJournal(raw, session) {
var text = String(raw == null ? "" : raw)
if (!text.trim()) return { status: "empty", entries: [], sequence: 0 }
if (text.length > JOURNAL_MAX_BYTES) return { status: "invalid", entries: [], sequence: 0, reason: "oversized" }
var data
try { data = JSON.parse(text) } catch (e) { return { status: "invalid", entries: [], sequence: 0, reason: "json" } }
if (!data || typeof data !== "object" || Array.isArray(data))
return { status: "invalid", entries: [], sequence: 0, reason: "shape" }
if (data.schema !== JOURNAL_SCHEMA)
return { status: "invalid", entries: [], sequence: 0, reason: "schema" }
if (typeof data.session !== "string" || !Array.isArray(data.entries))
return { status: "invalid", entries: [], sequence: 0, reason: "shape" }
if (!session || data.session !== String(session))
return { status: "stale", entries: [], sequence: 0, reason: "session" }
var entries = []
for (var i = 0; i < data.entries.length && entries.length < JOURNAL_MAX_ENTRIES; i++) {
var e = data.entries[i]
if (!e || typeof e !== "object") continue
var address = normalizeAddress(e.address)
if (!address) continue
entries.push({
address: address,
workspace: normalizeWorkspace(e.workspace),
class: sanitizeClass(e.class),
floating: e.floating === true,
fullscreen: clampInt(e.fullscreen, 0, 2, 0),
fullscreenClient: clampInt(e.fullscreenClient, 0, 2, 0),
pid: clampInt(e.pid, 0, 4194304, 0),
sequence: clampInt(e.sequence, 0, 2147483647, 0),
parkedAt: Math.max(0, Math.floor(Number(e.parkedAt) || 0)),
media: sanitizeMedia(e.media),
recovered: e.recovered === true
})
}
entries.sort(function (a, b) { return a.sequence - b.sequence })
return { status: "ok", entries: entries, sequence: clampInt(data.sequence, 0, 2147483647, 0) }
}
// Bring three sources into agreement: what we remember (state), what we
// persisted (journal entries), and what Hyprland actually has (live).
//
// live: [{ address, workspace, class, title, pid, floating, fullscreen,
// fullscreenClient }]
//
// Rules, in order of trust:
// * a window that is live on the park workspace is always recoverable;
// * a journal entry whose window is live elsewhere is dropped (someone
// already moved it back);
// * a journal entry with no live window becomes a Reopen only when the
// class is allowlisted, otherwise it is discarded;
// * a live parked window nobody remembers is exposed as "recovered".
//
// nowValue ("now" for restamping) is optional; when omitted the clock reads
// Date.now(). Tests pass an explicit value for determinism.
function reconcile(state, journalEntries, live, journalSequence, nowValue) {
var base = cloneState(state)
var park = base.parkWorkspace
var liveByAddr = {}
for (var i = 0; i < (live || []).length; i++) {
var w = live[i]
var addr = normalizeAddress(w && w.address)
if (addr) liveByAddr[addr] = w
}
var report = { recovered: [], converted: [], dropped: [], kept: [] }
var byAddress = {}
var merged = []
function consider(action) {
if (!action) return
if (action.type !== "park") {
merged.push(action)
return
}
if (byAddress[action.address]) return
var lw = liveByAddr[action.address]
if (lw && String(lw.workspace || "") === park) {
byAddress[action.address] = true
var refreshed = Object.assign({}, action)
if (lw.title) refreshed.label = shortLabel({ title: lw.title, class: action.class || lw.class })
if (!refreshed.class && lw.class) refreshed.class = sanitizeClass(lw.class)
merged.push(refreshed)
report.kept.push(action.address)
return
}
if (lw) {
// Live, but visible somewhere else: nothing to recover.
report.dropped.push(action.address)
return
}
if (relaunchCommand(action)) {
var re = relaunchAction(action, action.sequence)
re.address = ""
merged.push(re)
report.converted.push(action.address)
return
}
report.dropped.push(action.address)
}
for (var u = 0; u < base.undo.length; u++) consider(base.undo[u])
for (var j = 0; j < (journalEntries || []).length; j++) {
var e = journalEntries[j]
var journaled = parkAction(e, e.sequence)
// Pre-timeout journals carry no stamp: keep them exempt (parkedAt 0)
// instead of stamping "now", so enabling the timeout can never start
// the clock on history it cannot date.
if (!hasStamp(e.parkedAt)) journaled.parkedAt = 0
consider(journaled)
}
var seq = Math.max(base.sequence, clampInt(journalSequence, 0, 2147483647, 0))
for (var m = 0; m < merged.length; m++) seq = Math.max(seq, Number(merged[m].sequence || 0))
for (var addr2 in liveByAddr) {
if (byAddress[addr2]) continue
var lw2 = liveByAddr[addr2]
if (String(lw2.workspace || "") !== park) continue
seq += 1
var stranded = parkAction({
address: addr2,
workspace: "",
class: lw2.class || "",
title: lw2.title || "",
pid: lw2.pid || 0,
floating: !!lw2.floating,
fullscreen: 0,
fullscreenClient: 0,
recovered: true
}, seq)
if (!stranded.class) stranded.class = "window"
byAddress[addr2] = true
merged.push(stranded)
report.recovered.push(addr2)
}
merged.sort(function (a, b) { return Number(a.sequence || 0) - Number(b.sequence || 0) })
// Redo entries pointing at windows that no longer exist are noise.
var redo = base.redo.filter(function (a) {
if (!a || a.type !== "park") return !!a
return !!liveByAddr[a.address]
})
var next = cloneState(base)
next.undo = merged
next.redo = redo
next.sequence = seq
// A shell restart re-arms enforcement: with the timeout on, grant every
// kept window a fresh interval instead of expiring it on the spot for age
// accrued while the shell was down. Same grace as enabling at runtime;
// unstamped pre-timeout history and redo entries stay exempt.
if (clampParkTimeout(next.parkTimeout) > 0) next = restampParked(next, nowValue)
// Over capacity after recovery means the compositor holds more hidden
// windows than policy allows; expose them anyway rather than closing
// anything during startup. capUndo() only runs on new parks.
return { state: next, report: report }
}
if (typeof module !== "undefined") {
module.exports = {
PLUGIN_ID: PLUGIN_ID,
PARK_WORKSPACE: PARK_WORKSPACE,
DEFAULT_MAX: DEFAULT_MAX,
DEFAULT_PARK_TIMEOUT: DEFAULT_PARK_TIMEOUT,
MIN_PARK_TIMEOUT: MIN_PARK_TIMEOUT,
MAX_PARK_TIMEOUT: MAX_PARK_TIMEOUT,
JOURNAL_SCHEMA: JOURNAL_SCHEMA,
JOURNAL_MAX_BYTES: JOURNAL_MAX_BYTES,
JOURNAL_MAX_ENTRIES: JOURNAL_MAX_ENTRIES,
defaultExcludedClasses: defaultExcludedClasses,
clampMax: clampMax,
clampParkTimeout: clampParkTimeout,
FLIGHT_MODES: FLIGHT_MODES,
DEFAULT_FLIGHT: DEFAULT_FLIGHT,
normalizeFlight: normalizeFlight,
createState: createState,
cloneState: cloneState,
normalizeAddress: normalizeAddress,
normalizeWorkspace: normalizeWorkspace,
isSpecialWorkspace: isSpecialWorkspace,
canPark: canPark,
canRelaunch: canRelaunch,
sanitizeLabel: sanitizeLabel,
sanitizeClass: sanitizeClass,
sanitizeMedia: sanitizeMedia,
shortLabel: shortLabel,
toastLabel: toastLabel,
pushPark: pushPark,
pushRelaunch: pushRelaunch,
pushRecovered: pushRecovered,
undo: undo,
undoAt: undoAt,
redo: redo,
undoEffects: undoEffects,
attachMedia: attachMedia,
parkedAddresses: parkedAddresses,
findParked: findParked,
findBySequence: findBySequence,
removeBySequence: removeBySequence,
dropAddress: dropAddress,
markDead: markDead,
expireParked: expireParked,
restampParked: restampParked,
relaunchCommand: relaunchCommand,
restoreAll: restoreAll,
clear: clear,
reset: reset,
statusSummary: statusSummary,
toJournal: toJournal,
parseJournal: parseJournal,
reconcile: reconcile
}
}