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
(local {: application
        : caffeinate
        :fnutils {: contains : ifilter}
        : logger
        : notify
        : timer
        : window} hs)

(local log (logger.new :quitter :info))

(local config {:keep [:Arq
                      "CleanShot X"
                      :Dash
                      "Firefox Developer Edition"
                      :Ghostty
                      :Glide
                      :Miniflux
                      :Phanpy
                      :Obsidian
                      :Safari]
               :timeouts {:Fusion [:minute 30]
                          :Music [:hour 1]
                          :Raindrop [:minute 15]
                          "Raspberry Pi Imager" [:hour 1]
                          "Slay the Spire 2" [:hour 2]
                          :Steam [:hour 2]
                          "Steam Helper" [:hour 2]
                          "System Settings" [:hour 1]
                          "UniFi Protect" [:minute 30]
                          :Xcode [:hour 1]
                          :zoom.us [:hour 1]}
               :default [:minute 5]})

(fn kill-delay [app-name]
  (let [[interval count] (or (?. config.timeouts app-name) config.default)
        seconds (. {:minute 60 :hour (* 60 60)} interval)]
    (* count seconds)))

(local to-kill {})

(fn kill [app]
  ;; in theory if the app is frontmost it shouldn't
  ;; be marked, but an extra precaution since we never
  ;; want to kill the focused app
  (when (not (app:isFrontmost))
    (let [app-name (app:name)
          focused-win (window.focusedWindow)
          msg (.. "Killing " app-name)
          n (notify.new {:title :Quitter :informativeText msg :withdrawAfter 2})]
      (log.i msg)
      (n:send)
      (app:kill)
      (focused-win:focus))))

(fn unmark [app]
  ;; app can be nil - maybe when a window loses focus because its app is terminating?
  (when app
    (let [bundle-id (app:bundleID)
          t (?. to-kill bundle-id)]
      (when (not= t nil)
        (log.i (.. "unmarking " (app:name)))
        (t:stop)
        (set (. to-kill bundle-id) nil)))))

(fn mark [app]
  (when (and (not (contains config.keep (app:name))) (= (app:kind) 1))
    (unmark app)
    (let [delay (kill-delay (app:name))]
      (log.i (.. "marking " (app:name) " to be killed after " delay))
      (set (. to-kill (app:bundleID)) (timer.doAfter delay #(kill app))))))

(fn mark-all-apps []
  (log.d :mark-all-apps)
  (each [_ app (ipairs (ifilter [(application.find "")]
                                #(not (contains config.keep ($1:name)))))]
    (mark app)))

(local cw (caffeinate.watcher.new #(when (= $1 caffeinate.watcher.systemDidWake)
                                     (mark-all-apps))))

(local wf (let [filter-config {:default true}]
            (each [_ app-name (ipairs config.keep)]
              (tset filter-config app-name false))
            (window.filter.new filter-config)))

(fn start []
  (log.i :starting)
  (mark-all-apps)
  (cw:start)
  (wf:subscribe {window.filter.windowFocused #(unmark ($1:application))
                 window.filter.windowUnfocused #(mark ($1:application))}))

;; use global so this isn't GC'ed?
(set _G.quitter {: cw : to-kill : wf})

{: start}