main.go 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. package notify
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "fmt"
  6. "image"
  7. "io"
  8. "os"
  9. "slices"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "kitty/tools/cli"
  14. "kitty/tools/tty"
  15. "kitty/tools/tui/loop"
  16. "kitty/tools/utils"
  17. )
  18. var _ = fmt.Print
  19. const ESC_CODE_PREFIX = "\x1b]99;"
  20. const ESC_CODE_SUFFIX = "\x1b\\"
  21. const CHUNK_SIZE = 4096
  22. func b64encode(x string) string {
  23. return base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(x))
  24. }
  25. func check_id_valid(x string) bool {
  26. pat := utils.MustCompile(`[^a-zA-Z0-9_+.-]`)
  27. return pat.ReplaceAllString(x, "") == x
  28. }
  29. type parsed_data struct {
  30. opts *Options
  31. wait_till_closed bool
  32. expire_time time.Duration
  33. title, body, identifier string
  34. image_data []byte
  35. initial_msg string
  36. }
  37. func (p *parsed_data) create_metadata() string {
  38. ans := []string{}
  39. if p.opts.AppName != "" {
  40. ans = append(ans, "f="+b64encode(p.opts.AppName))
  41. }
  42. switch p.opts.Urgency {
  43. case "low":
  44. ans = append(ans, "u=0")
  45. case "critical":
  46. ans = append(ans, "u=2")
  47. }
  48. if p.expire_time >= 0 {
  49. ans = append(ans, "w="+strconv.FormatInt(p.expire_time.Milliseconds(), 10))
  50. }
  51. if p.opts.Type != "" {
  52. ans = append(ans, "t="+b64encode(p.opts.Type))
  53. }
  54. if p.wait_till_closed {
  55. ans = append(ans, "c=1:a=report")
  56. }
  57. for _, x := range p.opts.Icon {
  58. ans = append(ans, "n="+b64encode(x))
  59. }
  60. if p.opts.IconCacheId != "" {
  61. ans = append(ans, "g="+p.opts.IconCacheId)
  62. }
  63. if p.opts.SoundName != "system" {
  64. ans = append(ans, "s="+b64encode(p.opts.SoundName))
  65. }
  66. m := strings.Join(ans, ":")
  67. if m != "" {
  68. m = ":" + m
  69. }
  70. return m
  71. }
  72. var debugprintln = tty.DebugPrintln
  73. func (p *parsed_data) generate_chunks(callback func(string)) {
  74. prefix := ESC_CODE_PREFIX + "i=" + p.identifier
  75. write_chunk := func(middle string) {
  76. callback(prefix + middle + ESC_CODE_SUFFIX)
  77. }
  78. add_payload := func(payload_type, payload string) {
  79. if payload == "" {
  80. return
  81. }
  82. p := utils.IfElse(payload_type == "title", "", ":p="+payload_type)
  83. payload = b64encode(payload)
  84. for len(payload) > 0 {
  85. chunk := payload[:min(CHUNK_SIZE, len(payload))]
  86. payload = utils.IfElse(len(payload) > len(chunk), payload[len(chunk):], "")
  87. write_chunk(":d=0:e=1" + p + ";" + chunk)
  88. }
  89. }
  90. metadata := p.create_metadata()
  91. write_chunk(":d=0" + metadata + ";")
  92. add_payload("title", p.title)
  93. add_payload("body", p.body)
  94. if len(p.image_data) > 0 {
  95. add_payload("icon", utils.UnsafeBytesToString(p.image_data))
  96. }
  97. if len(p.opts.Button) > 0 {
  98. add_payload("buttons", strings.Join(p.opts.Button, "\u2028"))
  99. }
  100. write_chunk(";")
  101. }
  102. func (p *parsed_data) run_loop() (err error) {
  103. lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking, loop.NoInBandResizeNotifications)
  104. if err != nil {
  105. return err
  106. }
  107. activated := -1
  108. prefix := ESC_CODE_PREFIX + "i=" + p.identifier
  109. poll_for_close := func() {
  110. lp.AddTimer(time.Millisecond*50, false, func(_ loop.IdType) error {
  111. lp.QueueWriteString(prefix + ":p=alive;" + ESC_CODE_SUFFIX)
  112. return nil
  113. })
  114. }
  115. lp.OnInitialize = func() (string, error) {
  116. if p.initial_msg != "" {
  117. return p.initial_msg, nil
  118. }
  119. p.generate_chunks(func(x string) { lp.QueueWriteString(x) })
  120. return "", nil
  121. }
  122. lp.OnEscapeCode = func(ect loop.EscapeCodeType, data []byte) error {
  123. if ect == loop.OSC && bytes.HasPrefix(data, []byte(ESC_CODE_PREFIX[2:])) {
  124. raw := utils.UnsafeBytesToString(data[len(ESC_CODE_PREFIX[2:]):])
  125. metadata, payload, _ := strings.Cut(raw, ";")
  126. sent_identifier, payload_type := "", ""
  127. for _, x := range strings.Split(metadata, ":") {
  128. key, val, _ := strings.Cut(x, "=")
  129. switch key {
  130. case "i":
  131. sent_identifier = val
  132. case "p":
  133. payload_type = val
  134. }
  135. }
  136. if sent_identifier == p.identifier {
  137. switch payload_type {
  138. case "close":
  139. if payload == "untracked" {
  140. poll_for_close()
  141. } else {
  142. lp.Quit(0)
  143. }
  144. case "alive":
  145. live_ids := strings.Split(payload, ",")
  146. if slices.Contains(live_ids, p.identifier) {
  147. poll_for_close()
  148. } else {
  149. lp.Quit(0)
  150. }
  151. case "":
  152. if activated, err = strconv.Atoi(utils.IfElse(payload == "", "0", payload)); err != nil {
  153. return fmt.Errorf("Got invalid activation response from terminal: %#v", payload)
  154. }
  155. }
  156. }
  157. }
  158. return nil
  159. }
  160. close_requested := 0
  161. lp.OnKeyEvent = func(event *loop.KeyEvent) error {
  162. if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
  163. event.Handled = true
  164. switch close_requested {
  165. case 0:
  166. lp.QueueWriteString(prefix + ":p=close;" + ESC_CODE_SUFFIX)
  167. lp.Println("Closing notification, please wait...")
  168. close_requested++
  169. case 1:
  170. key := "Esc"
  171. if event.MatchesPressOrRepeat("ctrl+c") {
  172. key = "Ctrl+C"
  173. }
  174. lp.Println(fmt.Sprintf("Waiting for response from terminal, press the %s key again to abort. Note that this might result in garbage being printed to the terminal.", key))
  175. close_requested++
  176. default:
  177. return fmt.Errorf("Aborted by user!")
  178. }
  179. }
  180. return nil
  181. }
  182. err = lp.Run()
  183. ds := lp.DeathSignalName()
  184. if ds != "" {
  185. fmt.Println("Killed by signal: ", ds)
  186. lp.KillIfSignalled()
  187. return
  188. }
  189. if activated > -1 && err == nil {
  190. fmt.Println(activated)
  191. }
  192. return
  193. }
  194. func random_ident() (string, error) {
  195. return utils.HumanUUID4()
  196. }
  197. func parse_duration(x string) (ans time.Duration, err error) {
  198. switch x {
  199. case "never":
  200. return 0, nil
  201. case "":
  202. return -1, nil
  203. }
  204. trailer := x[len(x)-1]
  205. multipler := time.Second
  206. switch trailer {
  207. case 's':
  208. x = x[:len(x)-1]
  209. case 'm':
  210. x = x[:len(x)-1]
  211. multipler = time.Minute
  212. case 'h':
  213. x = x[:len(x)-1]
  214. multipler = time.Hour
  215. case 'd':
  216. x = x[:len(x)-1]
  217. multipler = time.Hour * 24
  218. }
  219. val, err := strconv.ParseFloat(x, 64)
  220. if err != nil {
  221. return ans, err
  222. }
  223. ans = time.Duration(float64(multipler) * val)
  224. return
  225. }
  226. func (p *parsed_data) load_image_data() (err error) {
  227. if p.opts.IconPath == "" {
  228. return nil
  229. }
  230. f, err := os.Open(p.opts.IconPath)
  231. if err != nil {
  232. return err
  233. }
  234. defer f.Close()
  235. _, imgfmt, err := image.DecodeConfig(f)
  236. if _, err = f.Seek(0, io.SeekStart); err != nil {
  237. return err
  238. }
  239. if err == nil && imgfmt != "" && strings.Contains("jpeg jpg gif png", strings.ToLower(imgfmt)) {
  240. p.image_data, err = io.ReadAll(f)
  241. return
  242. }
  243. return fmt.Errorf("The icon must be in PNG, JPEG or GIF formats")
  244. }
  245. func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
  246. if len(args) == 0 {
  247. return 1, fmt.Errorf("Must specify a TITLE for the notification")
  248. }
  249. var p parsed_data
  250. p.opts = opts
  251. p.title = args[0]
  252. if len(args) > 1 {
  253. p.body = strings.Join(args[1:], " ")
  254. }
  255. ident := opts.Identifier
  256. if ident == "" {
  257. if ident, err = random_ident(); err != nil {
  258. return 1, fmt.Errorf("Failed to generate a random identifier with error: %w", err)
  259. }
  260. }
  261. bad_ident := func(which string) error {
  262. return fmt.Errorf("Invalid identifier: %s must be only English letters, numbers, hyphens and underscores.", which)
  263. }
  264. if !check_id_valid(ident) {
  265. return 1, bad_ident(ident)
  266. }
  267. p.identifier = ident
  268. if !check_id_valid(opts.IconCacheId) {
  269. return 1, bad_ident(opts.IconCacheId)
  270. }
  271. if len(p.title) == 0 {
  272. if ident == "" {
  273. return 1, fmt.Errorf("Must specify a non-empty TITLE for the notification or specify an identifier to close a notification.")
  274. }
  275. msg := ESC_CODE_PREFIX + "i=" + ident + ":p=close;" + ESC_CODE_SUFFIX
  276. if opts.OnlyPrintEscapeCode {
  277. _, err = os.Stdout.WriteString(msg)
  278. } else if p.wait_till_closed {
  279. p.initial_msg = msg
  280. err = p.run_loop()
  281. } else {
  282. var term *tty.Term
  283. if term, err = tty.OpenControllingTerm(); err != nil {
  284. return 1, fmt.Errorf("Failed to open controlling terminal with error: %w", err)
  285. }
  286. if _, err = term.WriteString(msg); err != nil {
  287. term.RestoreAndClose()
  288. return 1, err
  289. }
  290. term.RestoreAndClose()
  291. }
  292. }
  293. if p.expire_time, err = parse_duration(opts.ExpireAfter); err != nil {
  294. return 1, fmt.Errorf("Invalid expire time: %s with error: %w", opts.ExpireAfter, err)
  295. }
  296. p.wait_till_closed = opts.WaitTillClosed
  297. if err = p.load_image_data(); err != nil {
  298. return 1, fmt.Errorf("Failed to load image data from %s with error %w", opts.IconPath, err)
  299. }
  300. if opts.OnlyPrintEscapeCode {
  301. p.generate_chunks(func(x string) {
  302. if err == nil {
  303. _, err = os.Stdout.WriteString(x)
  304. }
  305. })
  306. } else {
  307. if opts.PrintIdentifier {
  308. fmt.Println(ident)
  309. }
  310. if p.wait_till_closed {
  311. err = p.run_loop()
  312. } else {
  313. var term *tty.Term
  314. if term, err = tty.OpenControllingTerm(); err != nil {
  315. return 1, fmt.Errorf("Failed to open controlling terminal with error: %w", err)
  316. }
  317. p.generate_chunks(func(x string) {
  318. if err == nil {
  319. _, err = term.WriteString(x)
  320. }
  321. })
  322. term.RestoreAndClose()
  323. }
  324. }
  325. if err != nil {
  326. rc = 1
  327. }
  328. return
  329. }
  330. func EntryPoint(parent *cli.Command) {
  331. create_cmd(parent, main)
  332. }