Compare commits

..

881 commits
v0.2.4 ... main

Author SHA1 Message Date
Tulir Asokan
1a0f79108e web/menu: read emoji picker height from css
Some checks are pending
Go / Lint Go (old) (push) Waiting to run
Go / Lint Go (latest) (push) Waiting to run
JS / Lint JS (push) Waiting to run
2025-04-20 15:11:38 +03:00
Sumner Evans
af2fe22ec0
web/timeline: render ACL changes in details tag (#613)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-04-20 14:08:53 +03:00
Tulir Asokan
d6c4cdf716 hicli/send: fix mentions in captions
Fixes #617
2025-04-20 13:58:39 +03:00
Tulir Asokan
31e6b97371 web/emojipicker: limit number of category rows
Some checks failed
Go / Lint Go (old) (push) Has been cancelled
Go / Lint Go (latest) (push) Has been cancelled
JS / Lint JS (push) Has been cancelled
2025-04-18 00:44:36 +03:00
Tulir Asokan
3c4f65f366 web/emojipicker: add css variables for size 2025-04-18 00:40:46 +03:00
Tulir Asokan
1bfd457cf0 hicli/crypto: don't fail to decrypt even if decrypted content is malformed 2025-04-14 23:09:45 +03:00
Tulir Asokan
ef2e95a294 hicli/paginate: fill prev content when resyncing state
Some checks failed
Go / Lint Go (old) (push) Has been cancelled
Go / Lint Go (latest) (push) Has been cancelled
JS / Lint JS (push) Has been cancelled
2025-04-13 23:08:29 +03:00
Tulir Asokan
0bf0a35cee web/dependencies: update 2025-04-13 22:49:41 +03:00
Tulir Asokan
ef41c20192 hicli/html: implement shopmuks rendering 2025-04-13 22:49:01 +03:00
nexy7574
eb893989bd
web: implement knocking on rooms (#615) 2025-04-13 22:00:18 +03:00
Tulir Asokan
b754240715 hicli/sync: ignore invites with no state
Some checks are pending
Go / Lint Go (old) (push) Waiting to run
Go / Lint Go (latest) (push) Waiting to run
JS / Lint JS (push) Waiting to run
2025-04-13 14:47:02 +03:00
Tulir Asokan
a656ebb2e2 web/preferences: disable media previews by default
Some checks failed
Go / Lint Go (old) (push) Has been cancelled
Go / Lint Go (latest) (push) Has been cancelled
JS / Lint JS (push) Has been cancelled
2025-04-09 17:23:23 +03:00
Sebastian Spaeth
c2b12b1a88
web/statestore: don't hide video rooms (#614)
Some checks failed
Go / Lint Go (old) (push) Has been cancelled
Go / Lint Go (latest) (push) Has been cancelled
JS / Lint JS (push) Has been cancelled
2025-04-04 12:08:49 +03:00
Tulir Asokan
5052918462 web/keybindings: add cmd to composer autofocus exclude
Some checks are pending
Go / Lint Go (old) (push) Waiting to run
Go / Lint Go (latest) (push) Waiting to run
JS / Lint JS (push) Waiting to run
2025-04-04 01:43:17 +03:00
Tulir Asokan
a1097597d4 web/statestore: include policy list rooms in room list
Some checks failed
Go / Lint Go (old) (push) Has been cancelled
Go / Lint Go (latest) (push) Has been cancelled
JS / Lint JS (push) Has been cancelled
2025-04-01 00:06:59 +03:00
Tulir Asokan
f6687c9b0f dependencies: update 2025-03-31 22:40:40 +03:00
Tulir Asokan
c40a26a29c web/statestore: fix updating room list when room name changes 2025-03-31 21:21:30 +03:00
Tulir Asokan
fac69d1b63 web/roomcreate: add alias field and fix fonts
Some checks failed
Go / Lint Go (old) (push) Has been cancelled
Go / Lint Go (latest) (push) Has been cancelled
JS / Lint JS (push) Has been cancelled
2025-03-29 01:46:11 +02:00
Tulir Asokan
fd8e5150a0 web: add room create dialog
Fixes #610
2025-03-29 01:19:22 +02:00
Sumner Evans
f3dae06346
web/settings: add button to go to predecessor room (#612)
Some checks are pending
Go / Lint Go (old) (push) Waiting to run
Go / Lint Go (latest) (push) Waiting to run
JS / Lint JS (push) Waiting to run
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-03-28 17:16:12 +02:00
nexy7574
769d60c459
web/{timeline,composer}: render m.room.tombstone events (#608)
Some checks are pending
Go / Lint Go (old) (push) Waiting to run
Go / Lint Go (latest) (push) Waiting to run
JS / Lint JS (push) Waiting to run
2025-03-28 13:10:17 +02:00
Tulir Asokan
5f50cf8e77 hicli/paginate: don't panic if room is deleted while fetching state
Some checks are pending
Go / Lint Go (old) (push) Waiting to run
Go / Lint Go (latest) (push) Waiting to run
JS / Lint JS (push) Waiting to run
2025-03-27 12:33:11 +02:00
Tulir Asokan
d51518a190 web/login: don't allow logging in multiple times 2025-03-27 12:25:52 +02:00
Tulir Asokan
046a6d29a5 hicli/sync: fix discarding megolm sessions when new members are invited 2025-03-27 10:47:14 +02:00
Tulir Asokan
295d1f156e
readme: update 2025-03-16 23:02:32 +02:00
Tulir Asokan
c312a2b523 dependencies: update 2025-03-16 21:36:00 +02:00
Tulir Asokan
99b3fd0e5e web/index: move font size to html root 2025-03-16 21:31:58 +02:00
Tulir Asokan
c48f24d2de web/timeline: hide overflow in small replies 2025-03-15 12:32:43 +02:00
Tulir Asokan
696687f60c hicli/sync: ignore failing to recalculate preview 2025-03-15 12:32:31 +02:00
Tulir Asokan
4262b5abfa dependencies: update 2025-03-14 02:07:37 +02:00
Tulir Asokan
87ec9d60a5 hicli/paginate: fix handling non-empty final chunk 2025-03-14 02:06:19 +02:00
Tulir Asokan
9c17ce001d readme: update room alias 2025-03-14 02:00:57 +02:00
Tulir Asokan
0f2263ce8d web/timeline: implement MSC4205 for policy list rendering 2025-03-13 02:25:18 +02:00
Tulir Asokan
92d3ab64bf web/timeline: limit name size in small replies 2025-03-11 19:30:40 +02:00
Tulir Asokan
746e26fbc1 web/timeline: fix avatars in replies shrinking with long names 2025-03-11 19:28:25 +02:00
Tulir Asokan
3041fb18e3 web/rightpanel: fix displayname overflow in user info 2025-03-11 19:26:47 +02:00
Tulir Asokan
ede1c92906 dependencies: update mautrix-go 2025-03-10 01:26:33 +02:00
Tulir Asokan
0ed5dfcc12 hicli/sync: fix updating preview event rowid on redaction 2025-03-09 18:12:34 +02:00
Tulir Asokan
7f80301276 web/statestore: reload room view if state store is cleared 2025-03-09 18:05:39 +02:00
Tulir Asokan
3f4333003d web/preferences: fix letter 2025-03-09 17:31:04 +02:00
Tulir Asokan
218481f3a4 web/timeline: disable url preview images if image previews are disabled 2025-03-09 17:21:21 +02:00
Tulir Asokan
ef05bc71f9 web/roomlist: add option to hide invite avatars 2025-03-09 17:18:27 +02:00
Tulir Asokan
86843d61f6 web/preferences: add option to disable inline images 2025-03-09 16:55:55 +02:00
Tulir Asokan
6b9f6bebd5 web/widget: move iframe css outside right panel content 2025-03-09 16:41:50 +02:00
Tulir Asokan
aee4cff572 hicli/sync: clear outbound session on invite accept if history visibility is set to joined 2025-03-08 19:22:36 +02:00
Tulir Asokan
678940618f hicli/send: fix panic if history visibility event is not found 2025-03-08 18:43:19 +02:00
Tulir Asokan
6425f68c88 web/widget: add basic permission prompt
Fixes #605
2025-03-08 18:15:09 +02:00
Tulir Asokan
0db18f1e94 web/modal: allow non-dismissable modals 2025-03-08 16:37:27 +02:00
Tulir Asokan
fbad48129b web/widget: implement handling close event 2025-03-08 15:59:46 +02:00
Sumner Evans
b3b255e71c
web/modal: fix scrollbars (#606)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-03-08 13:55:42 +02:00
Tulir Asokan
5da85acfe0 web/timeline: fix message for rejecting invites 2025-03-07 22:53:06 +02:00
Tulir Asokan
f79678a87f web/widget: implement delayed state events 2025-03-06 01:29:40 +02:00
Tulir Asokan
23f2699909 push: log number of events in push payloads 2025-03-05 22:55:17 +02:00
Tulir Asokan
6e8dd0e591 web/widget/call: update parameters 2025-03-05 02:23:21 +02:00
Tulir Asokan
9a88df0cc1 web/widget: fix sendEvent not returning event ID properly 2025-03-05 02:10:29 +02:00
Tulir Asokan
3ef311d9d6 web/widget/call: use real homeserver base URL 2025-03-04 23:06:45 +02:00
Tulir Asokan
508355f2bf web/widgets: add initial support 2025-03-04 22:54:20 +02:00
Tulir Asokan
d234981604 web/timeline: add better messages for knocks 2025-03-03 23:55:30 +02:00
Tulir Asokan
e20f3161a2 web/css: fix button font 2025-03-03 12:57:16 +02:00
Tulir Asokan
c78ef2e97b web/devtools: add space between back and send buttons 2025-03-03 00:02:20 +02:00
Tulir Asokan
d093ea2f90 web/devtools: add send message event button 2025-03-02 21:06:29 +02:00
Tulir Asokan
e6242a9c37 web/roomview: add explore state button to header 2025-03-02 20:31:25 +02:00
Tulir Asokan
aeabda449d web/settings: add room state explorer
Fixes #516
Closes #526
2025-03-02 19:07:56 +02:00
Tulir Asokan
5aacc3424d web/settings: add readOnly flags to hidden fields to fix warnings 2025-03-02 17:16:21 +02:00
Tulir Asokan
d06ed8637c hicli/send: include geo_uri in edit fallback 2025-03-01 21:51:39 +02:00
Tulir Asokan
1ffb44fc27 web/timeline: fix message for unknown session errors 2025-03-01 21:45:41 +02:00
Tulir Asokan
83ea1c12ad web/statestore: fix applying undecryptable edits 2025-03-01 21:44:40 +02:00
Tulir Asokan
014c63f0e7 hicli/sync: ignore m.unavailable withheld events from own devices 2025-03-01 21:43:09 +02:00
Tulir Asokan
91fa59d5ba web/statestore: fix updating view when viewing redacted events 2025-03-01 21:39:11 +02:00
Tulir Asokan
b48c285f5c hicli/send: move extra to m.new_content when editing 2025-02-28 19:33:51 +02:00
Tulir Asokan
7168683a4b dependencies: update mautrix-go 2025-02-26 22:57:21 +02:00
Tulir Asokan
4247e17160 web/menu/roomlist: add confirmation for leaving 2025-02-25 23:24:43 +02:00
Tulir Asokan
62d8d06129 web/menu/roomlist: implement marking as read/unread and leaving room
Fixes #523
2025-02-25 22:15:11 +02:00
Tulir Asokan
1ba4d532c9 web/menu: move directory out of timeline 2025-02-25 22:15:11 +02:00
Tulir Asokan
bbce3df381 web/roomlist: add context menu for entries 2025-02-25 22:15:11 +02:00
Tulir Asokan
2203c18e15 web/rightpanel: add start DM button
Fixes #592
2025-02-25 22:15:11 +02:00
Tulir Asokan
bdae0c416f web/rightpanel: use border-bottom instead of hr for separators 2025-02-25 22:15:11 +02:00
Sumner Evans
b7cc6aff86
timeline: fix scrollbar issues on URL previews (#597)
* Prevents rendering an empty url-previews div
* Sets overflow-x to "auto" instead of "scroll"

Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-02-25 17:33:13 +02:00
Tulir Asokan
5d41b49462 web/composer: add option to start new thread when replying
Fixes #594
2025-02-24 00:08:32 +02:00
Tulir Asokan
548c8a9a94 web/timeline: remove unnecessary variable 2025-02-24 00:00:45 +02:00
Tulir Asokan
f9a8d3e042 web/timeline: use profile from prev_content for left users 2025-02-23 23:35:53 +02:00
Tulir Asokan
7ed0f2633c web/modal: make edit history modal fullscreen on mobile 2025-02-23 23:21:01 +02:00
Tulir Asokan
4a728e187c web/client: allow retrying unredacting 2025-02-23 21:30:36 +02:00
Tulir Asokan
c210f696cc web/timeline: fix server ACL rendering 2025-02-23 21:12:19 +02:00
Tulir Asokan
0c3d3686e4 hicli/sync: fill prev_content with cached event if it's missing 2025-02-23 21:06:53 +02:00
Tulir Asokan
bd2ece9ff2 hicli/sync: fix unredacting content of encrypted events 2025-02-23 18:44:59 +02:00
Tulir Asokan
a14f01a3ec web/timeline: implement MSC2815
Fixes #510
2025-02-23 18:33:19 +02:00
Tulir Asokan
4885dab2e1 web/modal: remove debug print 2025-02-23 18:04:08 +02:00
Tulir Asokan
42a97c8c1b dependencies: update 2025-02-23 18:03:12 +02:00
Tulir Asokan
4074e2ca68 web/modal: add error boundary for content 2025-02-23 18:03:12 +02:00
Tulir Asokan
4fc9b88ec6 web/timeline: add edit history modal 2025-02-23 18:03:12 +02:00
Tulir Asokan
a9e459b448 web/modal: allow two layers of modals 2025-02-23 18:03:12 +02:00
Tulir Asokan
7c664c2700 hicli/sync: fix sync backoff calculation 2025-02-23 18:03:12 +02:00
Tulir Asokan
c228b7f183 web/timeline: split body types by state key 2025-02-23 18:03:12 +02:00
Sumner Evans
5c27592b8c
web/timeline: add variable for vertical padding (#595)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-02-21 19:44:35 +02:00
Tulir Asokan
db709896d6 ci: add missing environment variable 2025-02-16 20:53:09 +02:00
Tulir Asokan
d1fd7f576a ci: update go version 2025-02-16 18:14:39 +02:00
Tulir Asokan
ce60eb8a94 hicli/paginate: add missing context cancel 2025-02-16 17:34:11 +02:00
Tulir Asokan
289b428644 dependencies: update 2025-02-16 17:33:22 +02:00
Tulir Asokan
2461cad4f2 web/all: add more error boundaries 2025-02-10 22:51:50 +02:00
Tulir Asokan
8ba9279cc7 web/rightpanel: fix handling non-string displaynames in member events 2025-02-10 22:33:16 +02:00
Tulir Asokan
3075156884 hicli/sync: ignore incorrectly detected member changes for megolm invalidation 2025-02-08 16:21:26 +02:00
Tulir Asokan
7df4d7c6f9 dependencies: update mautrix-go to fix content push rule details 2025-02-08 16:20:57 +02:00
Tulir Asokan
4060383efa hicli/sync: invalidate outbound sessions on member change 2025-02-07 19:36:48 +02:00
Tulir Asokan
14c9291c8d hicli/send: add discardsession command 2025-02-07 19:23:36 +02:00
Tulir Asokan
36ad528124 web/timeline: allow opening member event target user in right panel 2025-02-04 01:02:50 +02:00
Tulir Asokan
2665956654 web/timeline: handle making no change to someone else 2025-02-04 00:53:27 +02:00
Tulir Asokan
1e22e62a9a web/settings: add support for manual key import/export
Fixes #593
2025-02-04 00:40:08 +02:00
Tulir Asokan
717f2989a8 web/rightpanel: fix filtering member list 2025-01-30 14:50:40 +02:00
Tulir Asokan
9fd50a6ae3 media: fix saving thumbnail hashes 2025-01-28 14:52:10 +02:00
Tulir Asokan
947a853bae media: make thumbnail size configurable 2025-01-28 14:27:53 +02:00
Tulir Asokan
6d12e6e009 web: use thumbnails for small avatars 2025-01-27 23:14:09 +02:00
Tulir Asokan
1b5467cf0e media: add support for generating avatar thumbnails 2025-01-27 23:14:09 +02:00
Tulir Asokan
66c850717a web/stickerpicker: fix size on small screens 2025-01-27 17:37:37 +02:00
Tulir Asokan
2c489fa582 hicli/init: add safety for too many empty rooms 2025-01-27 12:48:57 +02:00
Tulir Asokan
e8c8a44f38 push: ignore missing members 2025-01-27 12:48:45 +02:00
Tulir Asokan
23fb7db2b9 web/lightbox: add support for touch panning/zooming 2025-01-26 22:50:21 +02:00
Tulir Asokan
e11c398a57 web/vite: allow all hosts by default 2025-01-26 21:57:21 +02:00
Tulir Asokan
19a4913a3f web/lightbox: fix zooming in when image is rotated 2025-01-26 21:55:21 +02:00
Tulir Asokan
0da4dede52 hicli/database: add get members method 2025-01-26 21:17:59 +02:00
Tulir Asokan
97add30a39 docker: add ffmpeg 2025-01-25 21:11:14 +02:00
Tulir Asokan
c2ab65e5c0 web/composer: don't allow media to be too wide 2025-01-25 16:03:04 +02:00
Tulir Asokan
15238b66f9 web/timeline: fix clicking spoilers and summaries on mobile 2025-01-24 01:47:03 +02:00
Sumner Evans
ce728417e5
web/timeline: disable right click context menu on videos (#587)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-01-24 01:39:36 +02:00
nexy7574
b7f939f480
web: add share event button (#589)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-01-24 01:39:10 +02:00
Tulir Asokan
865b2e4fdf web/rightpanel: don't show ignore button for self 2025-01-24 01:15:07 +02:00
Tulir Asokan
fabf3404af web/rightpanel: ignore timezone if value is unsupported 2025-01-24 01:04:28 +02:00
nexy7574
9cff332671
web: implement user moderation actions (#588)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-01-24 01:03:53 +02:00
Tulir Asokan
4649689b72 dependencies: update 2025-01-24 00:14:58 +02:00
Sumner Evans
5bb28d3216
web/composer: prevent sending when loading media (#590) 2025-01-24 00:14:49 +02:00
Tulir Asokan
f94d84b044 web/timeline: add validation for per-message profiles 2025-01-13 18:13:06 +02:00
Tulir Asokan
9e63da1b6b all: add FCM push support 2025-01-12 23:26:29 +02:00
Tulir Asokan
d4fc883736 web/timeline: fix typo in power level body 2025-01-11 20:45:48 +02:00
Tulir Asokan
5ab60cb816 web/mainscreen: handle url fragment change 2025-01-10 01:58:55 +02:00
Tulir Asokan
40e7d63453 desktop/dependencies: update wails 2025-01-10 01:57:35 +02:00
Tulir Asokan
b4ad603ea3 web/index: add user-scalable=no 2025-01-09 01:49:41 +02:00
Tulir Asokan
31bc7a6f24 dependencies: update 2025-01-06 17:26:37 +02:00
Sumner Evans
5b5df65f39
web/timeline: render MSC4144 per-message profiles (#566)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2025-01-06 15:08:34 +02:00
nexy7574
bdc823742e
web/timeline: render policy list events (#586)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-01-06 12:51:58 +00:00
Tulir Asokan
158745b7a0 web/statestore: clear unreads when rejecting invite or leaving room 2025-01-05 01:30:42 +02:00
Tulir Asokan
cb08f43535 web/rightpanel: use comma instead of slash as separator for pronoun sets 2025-01-03 16:29:21 +02:00
nexy7574
a1a006bf6b
web/rightpanel: show extended profile info for users (#574)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-01-03 14:27:02 +02:00
Tulir Asokan
f766b786ee web/eslint: add curly rule 2025-01-03 14:20:00 +02:00
Tulir Asokan
5d25d839f8 web: switch to first matching space when opening room
Fixes #582
2025-01-03 12:19:12 +02:00
Tulir Asokan
39cb5f28a0 hicli/database: store DM user ID in database 2025-01-02 23:23:30 +02:00
Tulir Asokan
ac6f2713e5 web/eslint: make line max length an error 2025-01-02 11:34:14 +02:00
Tulir Asokan
d8f0a82ffc web/roomlist: fix unread counter overflow condition 2025-01-02 11:17:22 +02:00
Tulir Asokan
3c3e2456e2 web/roomlist: switch space in unread click handler
On desktop it worked without this via event propagation, but that
doesn't work on mobile (possibly because the room view opens immediately
and hides the space bar?)
2025-01-02 11:16:30 +02:00
Tulir Asokan
021236592f web/statestore: clear unread counts when clearing state 2025-01-02 11:07:04 +02:00
Tulir Asokan
c3899d0b50 web/settings: add button to log into css.gomuks.app 2025-01-01 18:07:57 +02:00
Tulir Asokan
7f94bbf39e web/timeline: add background for read receipt avatars 2025-01-01 15:51:15 +02:00
Tulir Asokan
8c9925959a web/roomlist: don't allow selecting unread counter text 2025-01-01 15:47:08 +02:00
Tulir Asokan
ddf20b34d2 web/mainscreen: fix pushing history states when outside a space 2025-01-01 15:44:44 +02:00
Tulir Asokan
6d1c5f6277 web/roomlist: use margin instead of padding for room avatars
This allows adding a background for avatars using

```css
.avatar {
  background-color: var(--background-color);
}
```
2025-01-01 13:12:45 +02:00
Tulir Asokan
8b7d0fe6b6 web/roomlist: restore open space when using browser history 2025-01-01 12:24:22 +02:00
Tulir Asokan
59e1b760d6 web/statestore: fix clearing unread count after accepting invite 2025-01-01 01:22:33 +02:00
Tulir Asokan
43f25727e6 web/roomlist: add title for pseudo-spaces 2024-12-31 13:51:59 +02:00
Tulir Asokan
d0c35dda75 web/roomlist: close room when switching space 2024-12-31 13:51:21 +02:00
Tulir Asokan
7afcc37326 web/timeline: add missing dependency to effect 2024-12-30 23:29:42 +02:00
Tulir Asokan
f9b5fcc863 hicli/database: fix space parent revalidation query 2024-12-30 23:29:34 +02:00
Tulir Asokan
c9807df660 web/roomlist: jump to first unread when clicking space unread counter
Fixes #577
2024-12-30 22:59:03 +02:00
Tulir Asokan
c44ab253f8 web/timeline: fix rendering replies to unknown events in compact style 2024-12-30 22:40:14 +02:00
Tulir Asokan
d82f5404ec web/roomlist: fix unread count positioning 2024-12-30 22:33:02 +02:00
Tulir Asokan
572ef41b80 hicli/sync: fix processing space events 2024-12-30 22:33:02 +02:00
Tulir Asokan
e0f107f028 web/roomlist: add unread counters for spaces
Fixes #570
2024-12-30 16:46:19 +02:00
Tulir Asokan
b30025746d web/timeline: add close button to mobile context menu 2024-12-30 12:55:32 +02:00
Tulir Asokan
248a218eed web/timeline: fix mobile context menu bottom border again 2024-12-30 12:40:57 +02:00
Tulir Asokan
b3d63b7201 web/roomview: fix mobile context menu scroll 2024-12-30 11:01:22 +02:00
Tulir Asokan
df551fe4cb web/timeline: use custom message focus state on mobile to match context menu state 2024-12-30 10:54:13 +02:00
Tulir Asokan
c25ab057dc web/roomlist: add react key for real spaces 2024-12-30 10:18:36 +02:00
Derry Tutt
7a8d29b6de
web/composer: fix streched custom emojis in autocomplete (#565) 2024-12-29 20:12:47 +02:00
Tulir Asokan
534e36da22 media: use rect instead of circle in fallback avatar
Normal avatars already have border-radius. The raw svg being a rectangle
allows space squircles to work properly too.
2024-12-29 20:00:59 +02:00
Tulir Asokan
f4a778ecbb web/roomlist: add pseudo-space for space orphans 2024-12-29 20:00:59 +02:00
Tulir Asokan
6b01dec307 web/roomlist: add pseudo-spaces for unreads and DMs
Fixes #519
2024-12-29 20:00:59 +02:00
Tulir Asokan
5a8139685d web/roomlist: add space bar
Fixes #518
2024-12-29 20:00:59 +02:00
Tulir Asokan
5483b077c7 hicli/init: send spaces in first payload 2024-12-29 20:00:59 +02:00
Tulir Asokan
2ea80dac6f web/statestore: allow sync event fields to be null 2024-12-29 20:00:11 +02:00
Tulir Asokan
2b206bb32f hicli/database: don't store space depth 2024-12-29 20:00:04 +02:00
Tulir Asokan
326b06c702 hicli/database: store spaces edges 2024-12-29 19:59:58 +02:00
Tulir Asokan
622bc5d804 web/timeline: fix scrolling mobile context menu 2024-12-29 17:51:40 +02:00
Tulir Asokan
08a1712850 web/modal: don't capture input in context menu modal 2024-12-29 17:45:57 +02:00
Tulir Asokan
f83b914af0 web/timeline: align fixed menu size with room header 2024-12-29 17:01:08 +02:00
Tulir Asokan
8fa54a5bea web/timeline: fix fixed menu redact button color 2024-12-29 16:44:33 +02:00
Tulir Asokan
a1bddd6b6b web/timeline: add special message menu for mobile 2024-12-29 16:38:08 +02:00
Tulir Asokan
e750c19e8a web/mainscreen: fix handling popstate event to null state 2024-12-29 15:08:25 +02:00
Tulir Asokan
a0bc1b0d17 web/statestore: fix dm_user_id field in room list entries 2024-12-29 15:08:12 +02:00
Tulir Asokan
0b424e59bf web/roomlist: add bidi isolate for sender names 2024-12-28 17:19:33 +02:00
Tulir Asokan
8f46121413 web/login: fix login screen sizing 2024-12-27 20:04:38 +02:00
Tulir Asokan
8b1354b4a7 hicli/send: add /rawstate command 2024-12-27 16:48:45 +02:00
Tulir Asokan
57e067b671 web/stickerpicker: always include info
Fixes #568
2024-12-26 17:16:05 +02:00
Tulir Asokan
6aa4e91c0a web/css: fix background colors again 2024-12-25 14:20:04 +02:00
Tulir Asokan
eda7d6790e web/timeline: fix media spoilers
Closes #567
2024-12-25 14:07:10 +02:00
Tulir Asokan
7cad53bb7d web/css: ensure body background color applies to whole height 2024-12-25 13:56:19 +02:00
Tulir Asokan
5fbb8a21ab hicli/sync: fix detecting db lock errors 2024-12-23 22:46:14 +02:00
Tulir Asokan
b563c31a27 web/composer: fix sticker button size 2024-12-23 21:27:15 +02:00
Tulir Asokan
a0ce4f8cfe web/mainscreen: ensure sidebar resize handles are on top 2024-12-23 16:08:29 +02:00
Tulir Asokan
cebe5374fd web/preferences: add option to change favicon 2024-12-23 13:30:20 +02:00
Tulir Asokan
4c8497e5d9 web/preferences: add option to change window title
Fixes #564
2024-12-23 13:25:41 +02:00
Tulir Asokan
266116f237 web/timeline: don't render url previews on hidden events 2024-12-23 12:03:06 +02:00
Tulir Asokan
5fae264802 web/polyfill: make toArray work on map iterators 2024-12-23 11:35:23 +02:00
Tulir Asokan
277732efd9 web/app: add missing return to effect 2024-12-23 10:56:33 +02:00
Tulir Asokan
c5f78c6133 web/roomlist: remove separate inner component in room lists 2024-12-22 20:32:19 +02:00
Tulir Asokan
d359bd02d3 web/timeline: use transparent background color for url previews 2024-12-22 15:27:14 +02:00
Tulir Asokan
b01b3f0e32 web/all: move some things out of layout effects 2024-12-22 15:20:09 +02:00
Tulir Asokan
132a7dce15 dependencies: update mautrix-go 2024-12-22 15:01:00 +02:00
Tulir Asokan
202929ae3c web/modal: split context exports to separate file 2024-12-22 15:01:00 +02:00
Tulir Asokan
4160a33edb web/all: remove unnecessary uses of useCallback 2024-12-22 15:01:00 +02:00
Tulir Asokan
388be09795 web/timeline: don't shrink url previews 2024-12-22 15:01:00 +02:00
Tulir Asokan
e4182fc2d5 web/timeline: fix some things that make replies too big 2024-12-22 15:01:00 +02:00
Tulir Asokan
cac6db3909 web/timeline: remove small reply padding entirely 2024-12-22 02:07:41 +02:00
Tulir Asokan
588a994b55 web/timeline: allow opening lightbox from url preview image 2024-12-22 02:03:51 +02:00
Tulir Asokan
6830e5d1cc web/timeline: don't render locations in replies 2024-12-22 01:41:06 +02:00
Tulir Asokan
fa4d4144ba web/timeline: add option for compact replies
Closes #549
2024-12-22 01:33:46 +02:00
Sumner Evans
bb26bc4e64
web/{composer,timeline}: fix a couple loader colors (#561)
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2024-12-22 00:56:04 +02:00
Tulir Asokan
74842707b3 web/timeline: use css grid for url previews and improve inline style 2024-12-22 00:54:21 +02:00
Sumner Evans
cf857e459e
hicli/sync: fix caching media edits in encrypted rooms
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2024-12-21 13:30:11 -07:00
Sumner Evans
4c4744eba8
web/timeline: render MSC4095 URL previews
This commit implements rending of MSC4095[1] bundled URL previews and
includes a preference for disabling rendering of the previews.

[1]: https://github.com/matrix-org/matrix-spec-proposals/pull/4095

Signed-off-by: Sumner Evans <me@sumnerevans.com>
2024-12-21 10:38:44 -07:00
Tulir Asokan
1ff9ba241a web/emojipicker: don't allow pack names to wrap 2024-12-21 19:30:30 +02:00
Tulir Asokan
89895b197e web/stickerpicker: don't allow stickers to overflow vertically 2024-12-21 19:27:05 +02:00
Tulir Asokan
316fcc5bbd web/emojipicker: don't include sticker-only packs in emoji picker and vice versa 2024-12-21 19:24:00 +02:00
Tulir Asokan
6e25cc7f20 web/composer: fix editing text 2024-12-21 19:23:39 +02:00
Tulir Asokan
24836f5006 web/emojipicker: fix root margin for category underlining 2024-12-21 17:55:54 +02:00
Tulir Asokan
170e4cdae8 web/stickerpicker: fix word 2024-12-21 17:42:55 +02:00
Tulir Asokan
70938b2319 web/composer: add support for sending stickers 2024-12-21 17:34:37 +02:00
Tulir Asokan
42fa6ac465 hicli/sync: include replied-to events in sync and pagination
Should mostly fix #496
2024-12-21 16:04:39 +02:00
Tulir Asokan
4c95baa038 hicli/database: fix latest version number 2024-12-21 02:00:09 +02:00
Tulir Asokan
084a9f7141 web/roomview: use table correctly 2024-12-21 00:00:52 +02:00
Tulir Asokan
08830331d7 web/roomview: add invite metadata section
Closes #559
2024-12-20 23:52:45 +02:00
Tulir Asokan
800331f536 version: include goos and goarch in version output 2024-12-20 16:37:12 +02:00
Tulir Asokan
252a7bcbd1 web/settings: add support for leaving rooms 2024-12-20 16:37:12 +02:00
Tulir Asokan
da7eb6c583 hicli,web: add support for joining rooms
Fixes #503
2024-12-20 16:37:10 +02:00
Tulir Asokan
60a2e52a0c web/composer: close emoji picker after selecting 2024-12-19 23:19:04 +02:00
Tulir Asokan
79349cffc1 web/mainscreen: ensure back button goes to room lsit after update 2024-12-19 22:24:38 +02:00
Tulir Asokan
79a4a6cb48 web/mainscreen: remove outdated style 2024-12-18 22:12:26 +02:00
Tulir Asokan
e95f669ec5 web/preferences: allow imports in custom CSS 2024-12-18 22:11:10 +02:00
Tulir Asokan
ac9e6f356d web/preferences: add option to not render read receipts 2024-12-18 21:21:05 +02:00
Tulir Asokan
9cbc20bb07 web/timeline: don't render read receipts in pinned events 2024-12-18 20:30:59 +02:00
Tulir Asokan
10d3da6e7a web: make receipts and typing update after fetching member events 2024-12-18 20:18:32 +02:00
Tulir Asokan
0fbf76af98 hicli,web: add support for read receipts
Fixes #514
2024-12-18 03:34:27 +02:00
Tulir Asokan
158409db2f hicli/json-commands: disable get_events_by_rowids 2024-12-18 00:48:10 +02:00
Tulir Asokan
aa8148f5af hicli/database: add flag for events that had reply fallbacks removed 2024-12-18 00:46:59 +02:00
Tulir Asokan
29b787f94a hicli/sync: don't fail sync if database is locked 2024-12-17 22:34:54 +02:00
Tulir Asokan
0b1d5cd354 dependencies: update 2024-12-16 16:32:43 +02:00
Tulir Asokan
7f1a7efd7b
ci: build gomuks desktop binaries (#558) 2024-12-16 02:25:46 +02:00
Tulir Asokan
af05e8e86f web/notification: fix badge url and add silent flag 2024-12-15 19:56:17 +02:00
Tulir Asokan
0ebfc15ad7 web/composer: add preference for using ctrl+enter to send 2024-12-15 19:41:26 +02:00
Tulir Asokan
c038b517c6 web/roomview: add some readonly flags 2024-12-15 19:33:25 +02:00
Tulir Asokan
bf192a64a5 web/composer: collapse extra buttons when there's text 2024-12-15 19:33:25 +02:00
Tulir Asokan
b36b7b4e9d web/timeline/menu: don't allow context menu to overflow to the left 2024-12-15 19:11:48 +02:00
Tulir Asokan
7b7fbce4df web/mainscreen: add hack to skip transitions if browser provides them 2024-12-15 16:50:28 +02:00
Tulir Asokan
5f880b2487 web/mainscreen: add animation for entering rooms 2024-12-15 16:29:38 +02:00
Tulir Asokan
5455c88c2f web/manifest: add maskable and monochrome icons 2024-12-15 15:57:38 +02:00
Tulir Asokan
9a125f4240 web/index: add PWA manifest 2024-12-15 14:58:10 +02:00
Tulir Asokan
43b454eeed web/index: add resize-content viewport tag
This makes the viewport resize when opening mobile keyboards to ensure
that the input field is visible.
2024-12-15 14:39:10 +02:00
Tulir Asokan
b352255d14 web/roomview: scroll timeline to bottom on resize 2024-12-15 14:14:20 +02:00
Tulir Asokan
0512945c57 web/timeline: disable hover menu on mobile 2024-12-15 01:56:43 +02:00
Tulir Asokan
90e15dbab6 dependencies: update 2024-12-15 00:35:28 +02:00
Tulir Asokan
cd4655ac38 config: make origin patterns configurable 2024-12-14 22:30:58 +02:00
Tulir Asokan
68f8d4d372 ci: add windows/amd64 build 2024-12-14 22:19:28 +02:00
Tulir Asokan
a79c249688 hicli/database: add missing indexes for fast room deletes 2024-12-14 13:45:49 +02:00
Tulir Asokan
6f3619f632 web/rightpanel: add button to track user devices 2024-12-14 00:45:11 +02:00
Tulir Asokan
0fb9805c85 media: add media-src to CSP to work around chrome bug 2024-12-14 00:19:54 +02:00
Tulir Asokan
cee4e2f347 web/emojipicker: fix position above composer 2024-12-14 00:01:15 +02:00
Tulir Asokan
daa0ce722d web/timeline: fix context menu disable condition 2024-12-13 23:27:26 +02:00
Tulir Asokan
8a88b6d3d8 web/timeline: disable right click context menu in some cases 2024-12-13 15:57:48 +02:00
Tulir Asokan
a4933b76b8 web/timeline: move padding inside timeline-list 2024-12-13 15:23:23 +02:00
fd1f744993de14178e6c
02cc6cd07a
web/util: change button tooltip colour from #fff to --background-color (#557) 2024-12-13 13:27:43 +02:00
Tulir Asokan
185844468c web/timeline: add right click context menu for messages 2024-12-13 02:28:50 +02:00
Tulir Asokan
69c127a0a2 web/composer: use string join for typing names 2024-12-12 01:07:46 +02:00
Tulir Asokan
d9d3ad3d67 web/mainscreen: don't allow side bars to be more than 40% wide 2024-12-12 00:49:01 +02:00
Tulir Asokan
57475e2d48 web/rightpanel: center user avatar on mobile 2024-12-12 00:44:44 +02:00
Tulir Asokan
f80df9a8f5 web/composer: don't allow typing notifications to overflow 2024-12-12 00:43:10 +02:00
Tulir Asokan
ddd97655b0 hicli/send: add /unencrypted command 2024-12-11 02:27:20 +02:00
Tulir Asokan
35eb50cf8a web/composer: allow customizing reply notification and thread fallback behavior 2024-12-11 02:19:45 +02:00
Tulir Asokan
b664e45a97 web/composer: fix z-index 2024-12-11 01:36:54 +02:00
Tulir Asokan
1e347a4c00 main: don't stop server if it wasn't started 2024-12-11 01:04:01 +02:00
Tulir Asokan
f3da677565 web/composer: fix scrolling to bottom when starting to reply 2024-12-11 00:46:33 +02:00
Tulir Asokan
f0cb397316 web/timeline: fix member event line wrapping 2024-12-11 00:44:09 +02:00
Sumner Evans
0dda980b4a
web/timeline: don't show replied-to message if message is redacted (#548)
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-11 00:31:55 +02:00
Tulir Asokan
42a8933112 ci: run apt-get update 2024-12-11 00:28:01 +02:00
Tulir Asokan
56df141422
Merge pull request #543 from sumnerevans/render-typing-notifications
web/typing: render typing notifications below composer
2024-12-11 00:25:03 +02:00
Tulir Asokan
a1231c875b web/composer: fix weird 1px scroll 2024-12-11 00:21:05 +02:00
Tulir Asokan
6d744d90ba web/css: adjust some values 2024-12-11 00:08:59 +02:00
Tulir Asokan
a31b309e2e web/timeline: move horizontal padding to variable 2024-12-10 23:44:06 +02:00
Tulir Asokan
8052c29955 web/timeline: revert vertical padding changes in messages 2024-12-10 23:38:28 +02:00
Tulir Asokan
8ee34be466 Revert "web/util: fix oxfordHumanJoin(React)? for two-element arrays"
This reverts commit 3288d86e29.
2024-12-10 23:24:32 +02:00
Sumner Evans
ac3438ad25
timeline: make events go across entire width but with padding
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2024-12-09 08:33:49 -07:00
Sumner Evans
35b9397381
web/typing: render typing notifications below composer
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-08 21:47:55 -07:00
Sumner Evans
cf2c79e89e
web/composer: make floating
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-08 21:47:55 -07:00
Sumner Evans
3288d86e29
web/util: fix oxfordHumanJoin(React)? for two-element arrays
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-08 18:08:04 -07:00
Tulir Asokan
08f1f1b446 web/rightpanel: show user ID on hover in member list 2024-12-09 00:29:15 +02:00
Tulir Asokan
e9f146ebc7 web/rightpanel: add support for filtering member list 2024-12-09 00:13:21 +02:00
Tulir Asokan
1056a319bd web/{rightpanel,roomlist}: realign side headers with room view header 2024-12-08 17:35:55 +02:00
Tulir Asokan
3e460da67d web/util: remove oxfordHumanJoin 2024-12-08 17:34:11 +02:00
Tulir Asokan
332d71cadd web/roomlist: limit unread counter values 2024-12-08 17:32:05 +02:00
Jade Ellis
4ea43b77c7
web/roomlist: prevent overflow in unread counters (#541) 2024-12-08 17:31:48 +02:00
Sumner Evans
0133e7c037
pre-commit: use full relative path to eslint and tsc (#542)
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-08 17:11:26 +02:00
Tulir Asokan
1b0a8d6192 web/emojipicker: underline visible categories
Fixes #491
2024-12-08 01:02:20 +02:00
Tulir Asokan
152942663f server: remove sec header filter 2024-12-08 00:21:33 +02:00
Tulir Asokan
7b6807411f pre-commit: run tidy before vet 2024-12-08 00:21:24 +02:00
Tulir Asokan
d8ee580817 web/mainscreen: handle url hashes and update states consistently 2024-12-07 16:36:25 +02:00
Tulir Asokan
5638adf6bc web/main: automatically reload page if version changes 2024-12-07 16:25:15 +02:00
Tulir Asokan
5ee25b83d4 web/rightpanel: mark unverified devices as red if cross-signing keys exist 2024-12-07 15:11:02 +02:00
Tulir Asokan
cc2f334502 hicli/profile: use mautrix-go's GetCachedDevices 2024-12-07 15:05:31 +02:00
Tulir Asokan
a37a35795c main: don't initialize event buffer before loading config 2024-12-07 01:33:42 +02:00
Tulir Asokan
149114354a websocket/buffer: make size configurable 2024-12-07 01:31:05 +02:00
Tulir Asokan
894fcb3fa0 web/wsclient: reconnect automatically if disconnected 2024-12-07 01:22:16 +02:00
Tulir Asokan
7d6bbe77b9 websocket: add support for resuming sessions 2024-12-07 01:17:51 +02:00
Tulir Asokan
0930e94fb2
Merge pull request #538 from sumnerevans/render-room-topic
web/roomview: render room topic in header
2024-12-06 18:43:28 +02:00
Tulir Asokan
f9ae6bd031 web/rightpanel: fetch different user info sections separately 2024-12-06 18:37:22 +02:00
Sumner Evans
c6163806bd
web/settings: render room details in settings
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-06 09:32:31 -07:00
Sumner Evans
455c82a2c4
web/roomview: render room topic in header
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-06 09:32:31 -07:00
Tulir Asokan
838a9ce106 hicli/profile: check device signature for own cross-signing keys 2024-12-06 16:34:07 +02:00
Tulir Asokan
544bd17a1d web/util: decode fragment parts in matrix URIs 2024-12-06 16:10:24 +02:00
Tulir Asokan
2c7ad651e4 web/main: fix restoring state on reload 2024-12-06 16:05:11 +02:00
Tulir Asokan
bf7769ee95 web: add matrix: URI handler
Fixes #509
2024-12-06 16:05:09 +02:00
Tulir Asokan
a3873643ec web/preferences: fix saving deletions to localstorage 2024-12-06 16:04:42 +02:00
Tulir Asokan
3b05d14fbd web/main: add room name to page title 2024-12-06 16:04:42 +02:00
Tulir Asokan
b1c02a3b69 web/settings: add loading view for vs code 2024-12-06 14:25:19 +02:00
Tulir Asokan
92c5e86689 web: update react-spinners and stop using --legacy-peer-deps 2024-12-06 12:20:00 +02:00
Tulir Asokan
803505385a web/settings: embed vs code for editing custom css
Not meant for mobile yet
2024-12-06 02:27:54 +02:00
Tulir Asokan
63798f2298 web/dependencies: update to stable react 19 2024-12-06 01:21:51 +02:00
Tulir Asokan
5b9f458b75 web/rightpanel: improve user info view slightly 2024-12-06 01:11:32 +02:00
Tulir Asokan
1cc39d40c9 web/timeline: add variables for configuring timeline padding 2024-12-05 22:29:22 +02:00
Tulir Asokan
ca2ad94c5a web/css: align grid templates 2024-12-05 22:05:29 +02:00
Tulir Asokan
ab43472ebe web/roomlist: increase padding in room list entries 2024-12-05 22:03:50 +02:00
Tulir Asokan
f180077f0a media: generate blurhashes for video thumbnails 2024-12-05 19:47:43 +02:00
Tulir Asokan
fa80c1adc0 web/emojipicker: mobile optimize emoji and gif pickers 2024-12-05 19:43:52 +02:00
Tulir Asokan
714aa477b7 web/main: add ios safari compatibility 2024-12-05 19:29:12 +02:00
Tulir Asokan
2e5c2fdd6c web/polyfill: add missing semicolons 2024-12-05 18:39:58 +02:00
Tulir Asokan
b166302d1e web/mediasize: don't allow images to be too small 2024-12-05 18:27:59 +02:00
Tulir Asokan
7f30963f5e web/jsonview: fix trailing commas in arrays 2024-12-05 16:22:44 +02:00
Tulir Asokan
785f20c7dc web/rightpanel: improve error style in user view 2024-12-05 16:07:14 +02:00
Tulir Asokan
72dab88ed2 web/rightpanel: add more info to user view 2024-12-05 15:58:29 +02:00
Tulir Asokan
2f22159da3 web/main: also support back button for right panel 2024-12-04 02:00:44 +02:00
Tulir Asokan
1a2833ed53 web/main: add support for back button 2024-12-04 01:23:06 +02:00
Tulir Asokan
678743703c web/timeline: add resend button for failed messages 2024-12-04 00:48:04 +02:00
Tulir Asokan
529ffda4ed main: add support for logging out 2024-12-03 23:59:15 +02:00
Tulir Asokan
74439be24c gomuks: enable http2 read idle timeouts 2024-12-03 23:28:35 +02:00
Tulir Asokan
4245929da6 web/gifpicker: set file name properly 2024-12-03 17:22:54 +02:00
Tulir Asokan
d55952a1b6 web/composer: add gif picker 2024-12-03 01:23:08 +02:00
Tulir Asokan
dee7e5c72d web/timeline: remove redundant fragment around file download button 2024-12-02 23:38:32 +02:00
Tulir Asokan
229751a286 web/timeline: re-add loading=lazy for images 2024-12-02 23:33:52 +02:00
Tulir Asokan
12f9031ab1 web/composer: add support for sending location messages 2024-12-02 19:19:54 +02:00
Tulir Asokan
77219cb26e web/timeline: fix location marker icon in production builds 2024-12-02 17:00:12 +02:00
Tulir Asokan
09a586df3e web/settings: allow editing string fields 2024-12-02 16:46:02 +02:00
Tulir Asokan
68385fef5d web/timeline: add support for location messages 2024-12-02 16:44:35 +02:00
Tulir Asokan
b8fe8372f2 media: create blurhashes for outgoing images 2024-12-02 00:10:30 +02:00
Tulir Asokan
ab9dbbcd2f web/timeline: use content-visibility instead of loading=lazy for media 2024-12-02 00:02:42 +02:00
Tulir Asokan
b889f90c4d web/timeline: image size calculator for videos too 2024-12-01 23:56:27 +02:00
Tulir Asokan
42140aa0e0 web/timeline: add support for hiding media, blurhashes and spoilers
Closes #533
Closes #522
Fixes #504
2024-12-01 23:54:51 +02:00
Tulir Asokan
09fd60fdfe pre-commit: update shebang in pre-commit scripts 2024-12-01 23:49:30 +02:00
Tulir Asokan
462c2e978e web/timeline: add class to events sent by self
Closes #528
2024-12-01 22:19:48 +02:00
Tulir Asokan
76c17df0e6 web/composer: add min-height hack for safari
Closes #532
2024-12-01 22:17:03 +02:00
Tulir Asokan
1cc62da2a7 desktop/main: set initial device display name 2024-12-01 22:15:11 +02:00
Tulir Asokan
0d25f746a4 web/app: add another hack to detect wails properly 2024-12-01 22:12:40 +02:00
Tulir Asokan
c6f8d55201 web/vite: add safari18 and chrome131 to targets 2024-12-01 21:58:33 +02:00
Sumner Evans
b4e7efa799
flake: add devenv for Nix (#529)
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-01 21:49:32 +02:00
Jade Ellis
5d0642130d
web/css: set color-scheme property to match theme (#527) 2024-12-01 21:49:05 +02:00
Tulir Asokan
91676f3e98 web/timeline: add special style for spoiler reasons 2024-11-29 17:29:54 +02:00
Sumner Evans
90ca56f321
ci: fix Go lint task (#531)
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-27 11:56:08 +02:00
Tulir Asokan
1d134072c1 desktop: init 2024-11-27 03:24:57 +02:00
Tulir Asokan
2a6d5408bd main: move version info to separate package and refactor other things 2024-11-27 03:20:40 +02:00
Tulir Asokan
297193fa73 web/timeline: add room name and avatar event bodies 2024-11-27 01:13:27 +02:00
Tulir Asokan
ef937ae0d8 web/util: move type validation to utils 2024-11-27 01:13:14 +02:00
Tulir Asokan
f4020f588f web/api: add helper for getting room avatar url 2024-11-27 01:12:52 +02:00
Tulir Asokan
a0ab756562 web/timeline: add ACL event diffing
Fixes #492
2024-11-27 00:50:06 +02:00
Tulir Asokan
8ecbd2316c web/timeline: fix all small events being treated as hidden events 2024-11-26 23:16:44 +02:00
Tulir Asokan
ba6574fdb1 dependencies: update 2024-11-26 23:13:58 +02:00
Tulir Asokan
9cae8701e5 web/timeline: use form for confirmation modals 2024-11-26 23:05:34 +02:00
Tulir Asokan
83a4df9375 web/timeline: auto-load history until screen is full 2024-11-26 22:54:54 +02:00
Tulir Asokan
05f64edeaf web/statestore: add garbage collection
Fixes #490
2024-11-26 22:29:52 +02:00
Tulir Asokan
a59d10ae0c web/composer: fix selecting autocomplete item when not at end of composer 2024-11-26 22:27:48 +02:00
Tulir Asokan
573fc6a052 web/timeline: fix text wrap on member events again 2024-11-26 21:53:50 +02:00
Tulir Asokan
4349f7d75e main: use x/net/http2 directly and add option to disable http2 2024-11-22 01:11:15 +02:00
Sumner Evans
24c40fe484
build: use /usr/bin/env (#525) 2024-11-21 01:05:53 +02:00
Sumner Evans
2551540e99
web/timeline: highlight inline code (#524) 2024-11-21 01:05:28 +02:00
Tulir Asokan
f3717505bf hicli/sync,web/mainscreen: add sync status indicator
Fixes #500
2024-11-21 00:49:03 +02:00
Tulir Asokan
74e97c5c8c dependencies: update mautrix-go 2024-11-18 20:26:36 +02:00
Tulir Asokan
52feba5e6e web/timeline: fix line wrap in member events 2024-11-18 20:26:34 +02:00
Tulir Asokan
fa012554a7 web/stylepreferences: fix highlighting nested mention pills 2024-11-18 18:28:14 +02:00
Tulir Asokan
e76d508dc2 web/emojipicker: cut off long shortcodes 2024-11-18 18:28:14 +02:00
Tulir Asokan
ace847891b web/settings: fix css editor initial state 2024-11-18 00:34:31 +02:00
Tulir Asokan
4ee46c8df5 web/css: add shared class for primary color buttons 2024-11-18 00:30:29 +02:00
Tulir Asokan
9e795ed3bd web/login: reorder fields 2024-11-18 00:27:47 +02:00
Tulir Asokan
c3d9b2f922 web/login: don't do anything in submit before fetching login flows 2024-11-18 00:16:09 +02:00
Tulir Asokan
3a34576d88 config: enable file logging by default 2024-11-18 00:06:40 +02:00
Tulir Asokan
0bf4452e6e config: fix log directory on macOS and Windows 2024-11-18 00:02:35 +02:00
Tulir Asokan
7ac383b66d web/settings: use consistent words 2024-11-17 17:39:44 +02:00
Tulir Asokan
1041ebc232 web/settings: show every layer of settings objects 2024-11-17 17:38:39 +02:00
Tulir Asokan
216d16dccb web/settings: fix capitalization of word 2024-11-17 17:20:44 +02:00
Tulir Asokan
8ff5fff1de web/vite: split node modules and emojis into separate files 2024-11-17 16:58:52 +02:00
Tulir Asokan
2487c8c88f web/settings: add custom css editor 2024-11-17 14:46:57 +02:00
Tulir Asokan
63a1aa6cb7 web/settings: add hacky preference editor 2024-11-17 14:31:30 +02:00
Tulir Asokan
9babbe0fc7 web/settings: add stub settings view 2024-11-17 02:37:58 +02:00
Tulir Asokan
41074937d3 web/preferences: improve preference proxies 2024-11-17 02:37:45 +02:00
Tulir Asokan
f4be132313 web/modal: add boxing to modal utility 2024-11-17 02:36:01 +02:00
Tulir Asokan
303ea43834 web/timeline: fix icon import name 2024-11-17 01:59:24 +02:00
Tulir Asokan
4572a9c882 web/lightbox,web/modal: close on esc 2024-11-17 01:53:45 +02:00
Tulir Asokan
2b10509ceb web/timeline: don't omit profile if there's a date separator 2024-11-17 00:01:46 +02:00
Tulir Asokan
80f9a8bb6b hicli/database,web/roomlist: show marked unread status 2024-11-16 23:51:19 +02:00
Tulir Asokan
ead1365c12 dependencies: update 2024-11-16 23:21:02 +02:00
Tulir Asokan
b3cd8cc57e hicli/init: send room account data in initial payload 2024-11-16 23:19:50 +02:00
Tulir Asokan
1cef899e5c web/preferences: implement read receipt, typing notification and emoji pack options 2024-11-16 22:57:41 +02:00
Tulir Asokan
795eef1449 web/preferences: add options to hide redacted events, membership changes and date separators 2024-11-16 16:25:37 +02:00
Tulir Asokan
8f476839eb web: add preference system 2024-11-16 16:13:49 +02:00
Tulir Asokan
f3eb86455f web/composer: add limit to number of lines 2024-11-16 15:39:20 +02:00
Tulir Asokan
b585d72069 hicli/verify: add support for passphrase in addition to recovery key 2024-11-15 16:02:51 +02:00
Tulir Asokan
e0612ac0fb dependencies: update 2024-11-15 16:00:15 +02:00
Tulir Asokan
50eabb7b56 web/login: add support for SSO and Beeper email login
Fixes #493
2024-11-15 16:00:13 +02:00
Tulir Asokan
3df871b783 web/mainscreen: move modals inside main screen context 2024-11-14 14:11:29 +02:00
Tulir Asokan
bf4954e02f web/util: add pure css toggle element 2024-11-13 23:55:47 +02:00
Tulir Asokan
de405f9661 hicli/database: fix mass inserting state 2024-11-13 20:44:43 +02:00
Tulir Asokan
d3b93327f2 hicli/paginate: optimize storing members in massive rooms 2024-11-12 23:35:11 +02:00
Tulir Asokan
e370a12b19 web/store: only fetch full member list when needed 2024-11-12 22:47:28 +02:00
Tulir Asokan
0fe01a8bff web/client: fix fetching member lists 2024-11-12 21:33:17 +02:00
Tulir Asokan
04117b5211 web/rightpanel: implement member list 2024-11-12 21:19:37 +02:00
Tulir Asokan
24f2e3722d web/rightpanel: add basic user view 2024-11-12 17:58:18 +02:00
Tulir Asokan
fe6156302d web/timeline: decode matrix uris when clicking 2024-11-12 17:07:38 +02:00
Tulir Asokan
b552b07c74 web/timeline: insert mention when clicking name 2024-11-12 17:01:00 +02:00
Tulir Asokan
9b65140302 web/timeline: open user panel when clicking on avatar 2024-11-12 16:51:10 +02:00
Tulir Asokan
321b53a98a web/rightpanel: add back button to user view 2024-11-12 16:49:09 +02:00
Tulir Asokan
8097d5056b web/timeline: remove edit/reply/react buttons based on power levels 2024-11-12 14:18:01 +02:00
Tulir Asokan
465d7c3524 web/timeline: render reason in member events 2024-11-12 14:15:44 +02:00
Tulir Asokan
6bbc51d285 web/timeline: fix date comparison for day separators 2024-11-11 13:08:27 +02:00
Tulir Asokan
d22077a211 ci: rename workflow jobs 2024-11-10 21:55:23 +02:00
Tulir Asokan
d5c6d97d80 readme: update 2024-11-10 21:50:27 +02:00
Tulir Asokan
00a00c0be7 web/sounds: use lounger audio files 2024-11-10 18:29:02 +02:00
batuhan
05fbdaaf0e
cmd/gomuks: import frontend in main (#475)
This allows using pkg/gomuks as a library without the frontend
2024-11-10 17:35:23 +02:00
Tulir Asokan
bc550cbff4 web: add notification sound 2024-11-09 22:23:25 +02:00
Tulir Asokan
00a2070ff3 web/composer: fix enter behavior with empty autocomplete 2024-11-09 21:17:51 +02:00
Tulir Asokan
25e86fd381 web/timeline: revert overflow wrap for tables 2024-11-09 20:54:59 +02:00
Tulir Asokan
c2d0020c8c web/timeline: add click handlers for matrix URIs 2024-11-09 12:52:37 +01:00
Tulir Asokan
c6992b0fca web/main: use insertion effect for style injection 2024-11-08 15:27:23 +01:00
Tulir Asokan
0da7ed172d web/composer: fix comparison operator 2024-11-08 15:20:06 +01:00
Tulir Asokan
acbbd2a1f9 web/composer: adjust enter and tab behavior for autocomplete 2024-11-08 12:19:29 +01:00
Tulir Asokan
85817ea999 web/emoji: add regional indicators 2024-11-08 12:13:30 +01:00
Tulir Asokan
67f9bc348b web/roomlist: select first entry and clear query on enter 2024-11-08 12:02:41 +01:00
Tulir Asokan
5701bbf708 cmd/gomuks: move most things into new package 2024-11-08 10:09:30 +01:00
Tulir Asokan
540e8fa43e hicli/html: fix extra newlines when copying code blocks 2024-11-08 09:39:58 +01:00
Tulir Asokan
8ca664c745 web/jsonview: fix extra newlines when copying json 2024-11-07 18:48:24 +01:00
Tulir Asokan
219f92c222 ci: lock old issues automatically 2024-11-07 18:16:48 +01:00
Tulir Asokan
3df80686c4 web/roomlist: scroll entry to view when switching rooms 2024-11-07 17:53:53 +01:00
Tulir Asokan
c5452a570e web/keybindings: allow using page up/down and home/end in timeline 2024-11-07 17:22:44 +01:00
Tulir Asokan
bb52f1cfa9 web/composer: add enter and esc keybinds to autocompleter 2024-11-07 17:21:07 +01:00
Tulir Asokan
e0189a2651 web/timeline: fix hover menu z-index 2024-11-06 09:40:25 +01:00
Tulir Asokan
616c508ae3 web/timeline: disable event menu options until event is sent 2024-11-05 10:50:54 +01:00
Tulir Asokan
9e5f34aaca web/css: steal element's link text color 2024-11-04 15:13:14 +01:00
Tulir Asokan
d70529acda web/roomlist: hide preview text if message contains spoilers 2024-11-04 11:54:36 +01:00
Tulir Asokan
8f891b066c hicli/sync: don't send notifications on first sync 2024-11-04 11:52:51 +01:00
Tulir Asokan
24dea86c9e web/polyfill: actually fix iterator map 2024-11-04 11:50:52 +01:00
Tulir Asokan
ffa2b0fd51 web/polyfill: fix iterator map polyfill 2024-11-04 11:34:13 +01:00
Tulir Asokan
b63a81ba65 web: add Iterator.map polyfill for firefox 128 2024-11-04 12:16:26 +02:00
Tulir Asokan
c14c079d44 web/math: display error if katex fails 2024-11-02 19:56:02 +02:00
Tulir Asokan
a31c68fc5d web/roomlist: add button and keyboard shortcut to clear search filter 2024-11-02 19:50:54 +02:00
Tulir Asokan
ed45998248 hicli/send: add /raw command 2024-11-02 16:27:44 +02:00
Tulir Asokan
4742ca3116 hicli/send: fix image html to markdown conversion for editing 2024-11-02 14:03:39 +02:00
Tulir Asokan
c4d6e487da hicli/send: disable html input by default 2024-11-02 14:02:53 +02:00
Tulir Asokan
5832a935cf all: use markdown for custom emojis, improve editing
Edits will now use a different HTML -> markdown converter than what is
used to generate the body. This allows the plaintext body to have a
plain shortcode for custom emojis, while still having the raw data for
edits.

Additionally, for sent events, the raw input is saved locally, which
allows preserving commands and other such things. A future extension
may store the raw input in a custom field in the Matrix event to allow
lossless edits of messages sent from other clients.
2024-11-02 14:00:21 +02:00
Tulir Asokan
2ff3f9120c dependencies: update 2024-11-02 12:40:56 +02:00
Tulir Asokan
c31604eecf hicli/send: add LaTeX sending support 2024-11-02 12:32:00 +02:00
Tulir Asokan
438b5fb737 web/composer: use insertText instead of react state
This seems to be the only way to preserve the browser's native undo history
2024-11-02 11:17:04 +02:00
Tulir Asokan
8318cbdf17 hicli/html: fix non-math divs 2024-11-02 03:01:28 +02:00
Tulir Asokan
214d4fde53 hicli/html,web/timeline: add support for LaTeX rendering 2024-11-02 02:51:00 +02:00
Tulir Asokan
44dee015d4 hicli/html: deduplicate html escaping code 2024-11-02 02:50:56 +02:00
Tulir Asokan
249dffaa2d web/contentvisibility: work around another chrome bug 2024-11-02 00:37:33 +02:00
Tulir Asokan
2a89f10589 web/css: work around chrome issues and add contain rules 2024-11-02 00:16:51 +02:00
Tulir Asokan
f6b0b12a5b web/css: add variable for button cursor style 2024-11-02 00:08:04 +02:00
Tulir Asokan
babb69a639 web/composer: don't wrap pills in composer replies 2024-11-01 01:36:58 +02:00
Tulir Asokan
92319a06e2 web/keybindings: apply alt+up/down to filtered list instead of full 2024-11-01 01:34:03 +02:00
Tulir Asokan
245d81b9ce web/main: add keybindings for room list
Fixes #472
2024-11-01 01:30:33 +02:00
Tulir Asokan
f68070807c web/media: handle unicode correctly for fallback avatars 2024-10-31 21:52:05 +02:00
Tulir Asokan
d573f2fa58 web/roomview: don't allow room name to overflow 2024-10-31 00:54:34 +02:00
Tulir Asokan
244443c7fd web/timeline: highlight pills mentioning self 2024-10-31 00:43:09 +02:00
Tulir Asokan
f9b94034b1 web/rightpanel: center no pinned messages text 2024-10-31 00:21:23 +02:00
Tulir Asokan
54234036a7 web/rightpanel: close when same button is clicked again 2024-10-31 00:14:13 +02:00
Tulir Asokan
5095019f45 web/ui: move some files into subdirectories 2024-10-31 00:12:14 +02:00
Tulir Asokan
7ccca19c5d web/rightpanel: add support for viewing pinned messages 2024-10-31 00:09:51 +02:00
Tulir Asokan
8700626176 web/css: fix disabled button color 2024-10-30 23:06:24 +02:00
Tulir Asokan
5d2cc354f3 web/composer: fix autocomplete not overflowing 2024-10-30 23:04:30 +02:00
Tulir Asokan
39bfa7d084 web/roomlist: make room list panel resizable 2024-10-30 22:50:45 +02:00
Tulir Asokan
336f0aa100 web/composer: don't allow overflow 2024-10-30 19:50:04 +02:00
Tulir Asokan
3c22bfdea6 web/css: remove outdated comment 2024-10-29 18:25:34 +02:00
Tulir Asokan
a70c16f0f3 web/main: move authentication to happen after react init 2024-10-29 14:54:05 +02:00
Tulir Asokan
e2f0ba61ac hicli/html: open mxc urls in new tab 2024-10-29 14:38:23 +02:00
Tulir Asokan
ab97efbcc1 web/index: move code block stylesheets to html 2024-10-29 01:26:44 +02:00
Tulir Asokan
6fc070733a web/css: add dark theme 2024-10-29 00:58:29 +02:00
Tulir Asokan
4d7dbffe05 hicli/send: add support for /me and /notice 2024-10-29 00:27:36 +02:00
Tulir Asokan
0d3536a592 web/composer: clear reply when starting editing 2024-10-28 23:45:47 +02:00
Tulir Asokan
709428616b server: log auth result 2024-10-28 18:02:54 +02:00
Tulir Asokan
97b9f3248f web/timeline: don't allow confirmation modals to overflow 2024-10-28 16:12:34 +02:00
Tulir Asokan
65234a8214 main: remove nonexistent flag from help 2024-10-28 16:11:47 +02:00
Tulir Asokan
4fa6c83415 web/timeline: add ands to power level and pinned event diffs 2024-10-28 01:53:38 +02:00
Tulir Asokan
4bfa665937 web/timeline: add power level event rendering 2024-10-28 01:42:52 +02:00
Tulir Asokan
cffae7a3c8 Revert "web/app: initialize RPC client outside React"
This reverts commit 704ca2ca42.

It doesn't play nicely with vite's hot reloads
2024-10-28 00:43:25 +02:00
Tulir Asokan
9b73e755e8 web/timeline: add colors for user displaynames 2024-10-28 00:42:43 +02:00
Tulir Asokan
7e793ec0ba web/composer: surround selection with markdown when pasting link 2024-10-28 00:28:59 +02:00
Tulir Asokan
7c95ce35fd web/main: disable dark theme for code blocks 2024-10-28 00:16:18 +02:00
Tulir Asokan
a114b23b88 web: merge event dispatcher hooks 2024-10-28 00:12:00 +02:00
Tulir Asokan
11a8aac398 web: use default export for ClientContext 2024-10-28 00:07:57 +02:00
Tulir Asokan
704ca2ca42 web/app: initialize RPC client outside React 2024-10-28 00:05:48 +02:00
Tulir Asokan
0920c06077 web/timeline: open inline images in lightbox when clicked 2024-10-28 00:01:10 +02:00
Tulir Asokan
497e507783 hicli/sync: create implicit read receipts for own events 2024-10-27 23:38:24 +02:00
Tulir Asokan
0742feb365 web/timeline: use non-text cursor for spoilers 2024-10-27 21:50:44 +02:00
Tulir Asokan
ddfdc7c32a web: add shadow for non-dimmed modals 2024-10-27 19:46:06 +02:00
Tulir Asokan
497bc4e25a web/timeline: remove unnecessary !'s 2024-10-27 18:50:55 +02:00
Tulir Asokan
976d1ae9cb web/timeline: add big emojis 2024-10-27 18:40:43 +02:00
Tulir Asokan
fdec12a7a3 web/emoji: use custom emoji description as img title 2024-10-27 16:49:51 +02:00
Tulir Asokan
fa004a639e web/viewsource: add fancy JSON rendering 2024-10-27 16:30:45 +02:00
Tulir Asokan
6b4b12435a web/timeline: don't render reactions if there are none 2024-10-27 16:11:11 +02:00
Tulir Asokan
96019b6ef7 hicli: apply more default settings to http transport 2024-10-27 15:01:55 +02:00
Tulir Asokan
d432bbc26e hicli/backup: upload received megolm keys to key backup 2024-10-27 14:56:30 +02:00
Tulir Asokan
52fc7e5cdf web/client: preserve other fields when updating recent emoji 2024-10-27 13:12:15 +02:00
Tulir Asokan
21e2dcbc43 web/emoji: allow custom emojis in frequently used 2024-10-27 13:05:41 +02:00
Tulir Asokan
808bdbc068 web/timeline: bottom-align custom emojis 2024-10-27 12:57:25 +02:00
Tulir Asokan
4968dcc8d2 web/emoji: wrap reaction shortcode in :: 2024-10-27 12:49:37 +02:00
Tulir Asokan
ceb26a0e14 hicli/init: send account data last 2024-10-27 12:43:41 +02:00
Tulir Asokan
c407eb5c7d web/modal: allow closing modals with escape 2024-10-27 02:19:43 +03:00
Tulir Asokan
90e251dc18 web/emoji: add extra shortcodes for 🗑️ and 🚮 2024-10-27 02:14:58 +03:00
Tulir Asokan
e8f2029dbb web/timeline: add view source, report and redact event buttons to menu 2024-10-27 02:09:59 +03:00
Tulir Asokan
89a638a850 web/timeline: add messages for missing member changes 2024-10-27 01:46:08 +03:00
Tulir Asokan
98f8ca2cdc hicli/database: fix json tag on MegolmSessionID 2024-10-27 01:41:03 +03:00
Tulir Asokan
ca76182b81 hicli/html: fix extra </>'s in sanitized HTML 2024-10-27 01:40:42 +03:00
Tulir Asokan
11dad8541f web/emojipicker: make rendering lazier 2024-10-26 22:29:16 +03:00
Tulir Asokan
2de87fa645 web/mxtypes: fix reaction shortcode field 2024-10-26 20:44:37 +03:00
Derry Tutt
f1a28840ec
web/emojipicker: don't stretch custom emojis (#470) 2024-10-26 20:15:47 +03:00
Tulir Asokan
aaee239d6a web/timeline: make custom emojis bigger 2024-10-26 16:51:09 +03:00
Tulir Asokan
e699369f1f web/emoji: ignore spaces when searching 2024-10-26 16:49:59 +03:00
Tulir Asokan
64f9fccfd7 web/statestore: ignore packs with no images 2024-10-26 16:30:17 +03:00
Tulir Asokan
d9d0718bc6 web/emojipicker: add subscribe button for custom emoji packs 2024-10-26 16:28:56 +03:00
Tulir Asokan
ac3b906211 web/emoji: implement MSC2545 2024-10-26 15:49:04 +03:00
Tulir Asokan
96b11fca8e websocket: don't send initial data unless logged in 2024-10-26 14:30:14 +03:00
Tulir Asokan
2c92a69400 web/emoji: add more shortcodes 2024-10-26 01:06:13 +03:00
Tulir Asokan
15d696ae09 hicli/sync: skip linkifying for messages with no special characters 2024-10-25 23:43:12 +03:00
Tulir Asokan
8a34618a70 hicli/html: preallocate space for html sanitization buffer 2024-10-25 23:33:32 +03:00
Tulir Asokan
227ba474ef web/emojipicker: add support for frequently used emojis 2024-10-25 23:02:58 +03:00
Tulir Asokan
1e73867b9b web/composer: allow autocompleting after newline 2024-10-25 20:57:49 +03:00
Tulir Asokan
72e1bd428e hicli/sync: send account data to frontend 2024-10-25 19:15:22 +03:00
Tulir Asokan
5768b2202b web/main: set switchRoom in effect 2024-10-25 19:02:02 +03:00
Tulir Asokan
55a9866eac web/emojipicker: small improvements 2024-10-25 18:37:49 +03:00
Tulir Asokan
30222d2c6e web/timeline,composer: add emoji picker for composer and reaction sending 2024-10-25 16:58:11 +03:00
Tulir Asokan
d18b7a43a1 web/modal: add generic modal component 2024-10-25 16:35:06 +03:00
Tulir Asokan
692cb323a5 web/timeline: adjust paragraph margins and header font sizes 2024-10-25 14:19:04 +03:00
Tulir Asokan
b7975f1b4d web/emoji: pre-sort list 2024-10-25 13:06:41 +03:00
Tulir Asokan
854a929c92 web/timeline: fix scroll to bottom firing incorrectly in some cases 2024-10-25 03:29:32 +03:00
Tulir Asokan
f88b1b5b7f web/composer: allow switching edit target with arrow keys 2024-10-25 03:25:55 +03:00
Tulir Asokan
cc0067bb3f web/composer: add edit support 2024-10-25 03:03:15 +03:00
Tulir Asokan
a66c70c241 web/timeline: add hover menu for events
Includes pinning events and a `set_state` command in hicli
2024-10-25 01:50:16 +03:00
Tulir Asokan
c52600d0d7 web/timeline: split event content css to another file 2024-10-25 00:25:06 +03:00
Tulir Asokan
2dc9954030 media: escape html in fallback avatars 2024-10-24 22:23:48 +03:00
Tulir Asokan
8770205965 web/composer: escape markdown in displaynames when autocompleting mention 2024-10-24 14:54:55 +03:00
Tulir Asokan
6eef047ae5 hicli/html: add todo 2024-10-24 14:54:53 +03:00
Tulir Asokan
e9abcd50d1 web/composer: add slightly hacky user mention autocompleter 2024-10-24 02:14:46 +03:00
Tulir Asokan
0696a43208 web/composer: refactor autocompleter to be generic 2024-10-24 02:14:46 +03:00
Tulir Asokan
d31b0905ed web: set proper vite build target 2024-10-24 02:14:46 +03:00
Tulir Asokan
abc5327041 hicli: delete room data on leave 2024-10-24 02:14:46 +03:00
Tulir Asokan
9983a80eaa web: use less pointer cursors 2024-10-24 01:18:33 +03:00
Tulir Asokan
5189f86f81 hicli: lower http header timeout after first sync 2024-10-23 20:29:53 +03:00
Tulir Asokan
cc4eb16c7c web/timeline: prefer encrypted url if set 2024-10-23 16:02:20 +03:00
Tulir Asokan
508f2252da server/media: don't allow downloading encrypted media without flag 2024-10-23 15:59:14 +03:00
Tulir Asokan
68356a9ef1 server/media: use fallback avatar for unsupported mime types 2024-10-23 15:57:22 +03:00
Tulir Asokan
f0e3ec650d web/roomview: fix placeholder avatars in room view header 2024-10-23 13:37:27 +03:00
Tulir Asokan
a7a2fc53f2 web/composer: fix replies to code blocks being too wide 2024-10-23 13:35:20 +03:00
Tulir Asokan
30c579d1d4 web/timeline: add background color for pills 2024-10-23 13:33:38 +03:00
Tulir Asokan
e243593e06 server: add Cache-Control and ETag headers 2024-10-23 02:26:37 +03:00
Tulir Asokan
b24a34ef97 web/timeline: read relates_to from correct content 2024-10-23 01:21:21 +03:00
Tulir Asokan
1f521f8fac web/statestore: fix state subscribers 2024-10-22 22:14:26 +03:00
Tulir Asokan
11e1eef5e2 media,web: add support for fallback avatars 2024-10-22 21:56:08 +03:00
Tulir Asokan
eb68f3da7c web/timeline: fix some bugs with reading content 2024-10-22 21:55:27 +03:00
Tulir Asokan
e2458c7657 web/composer: fix using arrow keys to pick autocomplete results 2024-10-22 20:05:26 +03:00
Tulir Asokan
082e5642aa web/timeline: mark thread replies 2024-10-22 20:00:15 +03:00
Tulir Asokan
c4266fbc22 web/composer: send thread message when replying in thread 2024-10-22 19:53:10 +03:00
Tulir Asokan
014c8c07a8 hicli/database: add temporary hacky fix for reaction aggregations
The aggregations will be redone properly later
2024-10-22 16:36:49 +03:00
Tulir Asokan
0e328c44d3 web/timeline: wrap reactions 2024-10-22 12:57:34 +03:00
Tulir Asokan
65b221d38e web/timeline: add support for rendering custom emoji reactions 2024-10-22 01:34:27 +03:00
Tulir Asokan
27f1d0f3e4 hicli/sync: allow reprocessing encrypted event html 2024-10-22 01:15:45 +03:00
Tulir Asokan
9d96ed1b12 hicli/database: fix FillReactionCounts 2024-10-22 01:09:04 +03:00
Tulir Asokan
728db4d650 ci: build docker image 2024-10-22 01:07:40 +03:00
Tulir Asokan
f5288f4922 web/statestore: also notify state subscribers when fetching whole state 2024-10-21 23:28:41 +03:00
Tulir Asokan
293a6416fc web/timeline: add a bubble around reactions 2024-10-21 23:21:40 +03:00
Tulir Asokan
f5eeb8461a hicli/sync: enable reaction count collection for all events
The reaction aggregation probably needs to be redone to support finding
the entire event (to see senders and other content like shortcodes),
but this is good enough to get reactions rendering.
2024-10-21 23:11:14 +03:00
Tulir Asokan
3296c38454 web/timeline: subscribe to event sender state 2024-10-21 23:04:57 +03:00
Tulir Asokan
ab06dbf5aa web/timeline: don't allow sender to wrap 2024-10-21 23:04:57 +03:00
Tulir Asokan
cc56633732 web/timeline: override code block background color 2024-10-21 22:14:07 +03:00
Tulir Asokan
aae6e7496c web/roomlist,timeline: memoize rooms and messages 2024-10-21 22:10:40 +03:00
Tulir Asokan
52b4e2d6d1 web/timeline: remove unused css rule 2024-10-21 20:57:48 +03:00
Tulir Asokan
f2f89b728f web/timeline: add error boundary for event content rendering 2024-10-21 20:52:59 +03:00
Tulir Asokan
b67095f0fd web/timeline: reorganize components slightly 2024-10-21 20:42:22 +03:00
Tulir Asokan
e1c937849e web/timeline: fix rendering replies to non-message events 2024-10-21 20:35:20 +03:00
Tulir Asokan
d73cf4d863 web/roomview: focus composer on ctrl+a as well 2024-10-21 00:51:29 +03:00
Tulir Asokan
ecd8064adb web/roomlist: use content-visibility auto for room list entries 2024-10-21 00:36:17 +03:00
Tulir Asokan
716f8b9834 web/autocomplete: scroll selected item into view 2024-10-20 22:26:52 +03:00
Tulir Asokan
8ddf5f800d web/composer: add emoji autocompletion 2024-10-20 22:11:49 +03:00
Tulir Asokan
9b1b0426c3 media: allow streaming unencrypted files 2024-10-20 19:48:22 +03:00
Tulir Asokan
0e3f6bdacb web/composer: use relative path for upload endpoint 2024-10-20 18:42:47 +03:00
Tulir Asokan
2744fcf213 web/vite: set relative base path 2024-10-20 18:35:27 +03:00
Tulir Asokan
ef3776f2ce web/main: fix path for websocket request too 2024-10-20 18:30:48 +03:00
Tulir Asokan
732b38490c web/main: disable pinch to zoom on mobile 2024-10-20 18:17:05 +03:00
Tulir Asokan
24eba06330 web/main: fix path for auth request 2024-10-20 18:16:43 +03:00
Tulir Asokan
4abeaf2250 web/timeline: fix code block reply line limit again 2024-10-20 16:09:20 +03:00
Tulir Asokan
afa6d3aa4b hicli/html,web/timeline: add syntax highlighting for code blocks 2024-10-20 15:55:14 +03:00
Tulir Asokan
8cc475e66b hicli/json: add resolve alias command 2024-10-20 14:10:01 +03:00
Tulir Asokan
34346cffec hicli/html: linkify plaintext matrix: and mxc:// URIs 2024-10-20 13:51:55 +03:00
Tulir Asokan
535393d47f hicli/html: add inline images to media references 2024-10-20 13:31:33 +03:00
Tulir Asokan
2c5738f7f2 hicli/database: refactor media cache 2024-10-20 13:21:34 +03:00
Tulir Asokan
0b59b2c733 hicli/pushrules: skip evaluating non-message events in initial sync 2024-10-20 12:52:37 +03:00
Tulir Asokan
251c91490c web/roomlist: lowercase search queries before unhomoglyphing 2024-10-20 12:38:04 +03:00
Tulir Asokan
c0a8deb347 web/html: remove old client-side html sanitizer 2024-10-20 12:34:12 +03:00
Tulir Asokan
e31bb4ebb6 web/roomlist: hide tombstoned rooms and unknown room types 2024-10-20 12:28:31 +03:00
Tulir Asokan
4b9e28c644 hicli/database: store tombstone content in room table 2024-10-20 12:28:09 +03:00
Tulir Asokan
ba30026cdc server: set secure flag for cookie 2024-10-20 12:19:51 +03:00
Tulir Asokan
544b90f3d9 server: set same-site attribute in cookies 2024-10-20 12:18:54 +03:00
Tulir Asokan
066c3ff0d3 web/composer: fix textarea font 2024-10-20 01:34:58 +03:00
Tulir Asokan
f38946b96e hicli/sync: make sorting timestamps more sensible on init sync 2024-10-20 01:20:35 +03:00
Tulir Asokan
56f11fcb8f web: only underline links on hover 2024-10-19 21:33:37 +03:00
Tulir Asokan
b15db20347 web/notifications: close notification when room is read from another client 2024-10-19 21:09:55 +03:00
Tulir Asokan
7676f21292 web/timeline: add different rendering for m.notice and m.emote messages 2024-10-19 17:41:36 +03:00
Tulir Asokan
e40a97f43b hicli/html: correctly linkify plaintext emails 2024-10-19 17:41:36 +03:00
Tulir Asokan
c6cdb820ea hicli/html: convert plaintext matrix.to links to matrix: URIs 2024-10-19 17:41:36 +03:00
Tulir Asokan
3fdaf8ae4e web/timeline: allow jumping to reply if it's loaded in the timeline 2024-10-19 17:41:36 +03:00
Tulir Asokan
e6121149b3 config: use readline for initializing username/password 2024-10-19 16:17:58 +03:00
Tulir Asokan
8e92d9b163 server: remove cookie if it's invalid 2024-10-19 16:15:39 +03:00
Tulir Asokan
ad224073fe server: return www-authenticate again on incorrect password 2024-10-19 16:13:55 +03:00
Tulir Asokan
f252323b04 web/composer: don't autofocus on mobile 2024-10-19 16:09:43 +03:00
Tulir Asokan
a2353409bf web/composer: add support for pasting files 2024-10-19 15:53:40 +03:00
Tulir Asokan
24342a5dce web/composer: add support for rich drafts 2024-10-19 15:41:13 +03:00
Tulir Asokan
b37e4644b7 web/composer: fix line height 2024-10-19 14:53:33 +03:00
Tulir Asokan
d3decc5255 hicli/pushrules: add todo for saving mute flag in rooms 2024-10-19 02:35:15 +03:00
Tulir Asokan
957f3eb5aa web/timeline: fix rendering kick events 2024-10-19 02:35:15 +03:00
Tulir Asokan
e78bf640ff server,web/composer: add support for sending media 2024-10-19 02:35:15 +03:00
Tulir Asokan
7fbdfffd90 hicli/send: encrypt message asynchronously 2024-10-19 02:02:04 +03:00
Tulir Asokan
83abfe7892 hicli/pushrules: fix panic if power level event is missing 2024-10-18 20:38:37 +03:00
Tulir Asokan
e2b8c0e993 ci: actually fix build command 2024-10-18 15:19:17 +03:00
Tulir Asokan
37e43a41e4 web/notifications: increase max length 2024-10-18 15:16:44 +03:00
Tulir Asokan
95e9813ff3 ci: fix build commands 2024-10-18 15:16:26 +03:00
Tulir Asokan
7601609683 hicli/pushrules: add support for room mentions 2024-10-18 14:11:18 +03:00
Tulir Asokan
3dd7f9a4bd web/timeline: fix newlines in plaintext messages 2024-10-18 12:56:38 +03:00
Tulir Asokan
8d201642c8 hicli/html: fix closing font tags in html sanitizer 2024-10-18 12:27:16 +03:00
Tulir Asokan
475a57e3f5 hicli/verify: return error if secret is missing 2024-10-18 02:30:54 +03:00
Tulir Asokan
3dd083fc1c server: fix isUserFetch check 2024-10-18 01:45:49 +03:00
Tulir Asokan
e00baf6853 web/timeline: highlight messages that mention you 2024-10-18 01:16:13 +03:00
Tulir Asokan
2179fb2c18 hicli/sync: recalculate unreads on redaction 2024-10-18 00:57:27 +03:00
Tulir Asokan
9254461795 hicli/sync: always send room in sync if own receipts change 2024-10-17 23:22:29 +03:00
Tulir Asokan
d1dedd51fe web/timeline: fix unspoilering spoilers 2024-10-17 23:09:37 +03:00
Tulir Asokan
00630f997d web/roomlist: render unread message counts 2024-10-17 22:07:28 +03:00
Tulir Asokan
0455ff3d24 hicli: calculate unreads locally 2024-10-17 21:49:57 +03:00
Tulir Asokan
504e2bd976 main: move into cmd directory 2024-10-17 20:40:34 +03:00
Tulir Asokan
1550d534f8 websocket: move generating initial sync into hicli 2024-10-17 20:37:38 +03:00
Tulir Asokan
1db1d2db5c all: move hicli from mautrix-go and add more features 2024-10-17 20:31:03 +03:00
Tulir Asokan
d79be2b8cf dependencies: update mautrix-go 2024-10-16 17:34:47 +03:00
Tulir Asokan
1ad5a14d0f web/util: fix focus event 2024-10-16 17:01:03 +03:00
Tulir Asokan
0bbb84c6d1 web: adjust some styles 2024-10-16 17:01:03 +03:00
Tulir Asokan
ee88489a9b dotfiles: remove unused codeclimate file 2024-10-15 15:06:01 +03:00
Tulir Asokan
b31eb2ea75 web/main: make room view and list separate screens on mobile 2024-10-15 13:54:22 +03:00
Tulir Asokan
747a015bcc web/timeline: add overflow wrap for message bodies 2024-10-15 13:34:23 +03:00
Tulir Asokan
90e68875f1 server: only validate sec-fetch headers if present 2024-10-15 12:03:45 +03:00
Tulir Asokan
a4d1a7feeb build.sh: add build script 2024-10-15 02:31:22 +03:00
Tulir Asokan
cf56cd24aa dependencies: update mautrix-go 2024-10-15 02:22:31 +03:00
Tulir Asokan
038e62120b server: add config option to enable pprof endpoints 2024-10-15 02:19:21 +03:00
Tulir Asokan
08bea53cf1 dependencies: update mautrix-go 2024-10-15 01:53:20 +03:00
Tulir Asokan
f0de332b00 dependencies: update mautrix-go to use user avatar as DM room avatar 2024-10-15 01:43:02 +03:00
Tulir Asokan
1c10ba9348 dependencies: update mautrix-go 2024-10-15 01:21:37 +03:00
Tulir Asokan
8eaf5f7a4f web/timeline: don't send read receipts for own messages 2024-10-15 01:16:24 +03:00
Tulir Asokan
d3fef15c56 server: username isn't too long to fit token 2024-10-15 01:08:23 +03:00
Tulir Asokan
89ece7fb45 web/timeline: send read receipts 2024-10-15 01:06:32 +03:00
Tulir Asokan
3c596a200f web/composer: send typing notifications 2024-10-15 00:40:06 +03:00
Tulir Asokan
c16a2c2c80 server: remove header validation for websockets 2024-10-15 00:14:55 +03:00
Tulir Asokan
ce43c6946c web/composer: store drafts in localStorage 2024-10-15 00:06:00 +03:00
Tulir Asokan
3536aa1569 web/timeline: force pre blocks to be inline in replies to apply line limit 2024-10-14 23:24:43 +03:00
Tulir Asokan
d77534c1de web/util: move identifier validation functions to separate file 2024-10-14 23:24:17 +03:00
Tulir Asokan
c4c5563f9a web/timeline: make reply placeholder 2 lines long 2024-10-14 23:04:04 +03:00
Tulir Asokan
73d8c5c6bb dependencies: update mautrix-go 2024-10-14 17:35:06 +03:00
Tulir Asokan
876ddaf51a web/statestore: fix second form of applying edits 2024-10-14 17:34:09 +03:00
Tulir Asokan
716b43ebd1 web/timeline: adjust timestamp and member event rendering 2024-10-14 16:46:00 +03:00
Tulir Asokan
bd52d758b9 web/roomview: focus input on ctrl+v 2024-10-14 16:45:46 +03:00
Tulir Asokan
ff690e50af web/statestore: support edit event being processed after last_edit_rowid update 2024-10-14 01:55:49 +03:00
Tulir Asokan
464cd3fe3e web/timeline: add more padding to events 2024-10-14 01:55:49 +03:00
Tulir Asokan
a6d9ff542c dependencies: update mautrix-go 2024-10-14 01:55:49 +03:00
Tulir Asokan
6933665795 web/timeline: make event timestamp stand out less 2024-10-14 01:27:59 +03:00
Tulir Asokan
e9834fd987 web: request replied-to event if it's not cached 2024-10-14 01:26:33 +03:00
Tulir Asokan
bbc59a2f89 web/statestore: split into multiple files 2024-10-14 00:59:30 +03:00
Tulir Asokan
2fc1aff753 web/lightbox: ignore open call without src 2024-10-14 00:45:32 +03:00
Tulir Asokan
7ef6509d46 web/timeline: fix rendering replies in edited events 2024-10-13 23:37:51 +03:00
Tulir Asokan
ca6736f892 web/roomview: don't focus input when copying text 2024-10-13 23:31:35 +03:00
Tulir Asokan
e22e72b335 web/timeline: add proper rendering of member events 2024-10-13 23:10:29 +03:00
Tulir Asokan
3dc86b287a web/roomview: add support for sending replies 2024-10-13 22:34:17 +03:00
Tulir Asokan
f238bb0285 web/roomview: focus composer when typing elsewhere 2024-10-13 21:16:23 +03:00
Tulir Asokan
d0cedfa3c3 web/timeline: add space between reply and content 2024-10-13 20:52:48 +03:00
Tulir Asokan
c281ba90ee web/roomlist: add filter bar 2024-10-13 20:52:30 +03:00
Tulir Asokan
8f43d00d06 web/roomlist: make background color prettier 2024-10-13 18:46:15 +03:00
Tulir Asokan
6c55f1654c web/timeline: add support for other file types 2024-10-13 18:31:22 +03:00
Tulir Asokan
11aa2eabf1 web/api: add mark read method 2024-10-13 17:14:56 +03:00
Tulir Asokan
ec165d171c web/timeline: add basic reply rendering 2024-10-13 17:13:40 +03:00
Tulir Asokan
e909f4f994 websocket: fix logging commands with no data 2024-10-13 17:12:06 +03:00
Tulir Asokan
4eec9f5eb6 web: allow @ imports 2024-10-13 17:11:43 +03:00
Tulir Asokan
cd768d6f2e web/roomlist: use "You" as sender for own previews 2024-10-13 15:51:20 +03:00
Tulir Asokan
4e55fa97bc dependencies: update mautrix-go 2024-10-12 18:57:48 +03:00
Tulir Asokan
50024fbc1d web/timeline: add better placeholder for redactions and undecryptable events 2024-10-12 18:52:23 +03:00
Tulir Asokan
371cd599a8 web/timeline: add date separators 2024-10-12 18:37:46 +03:00
Tulir Asokan
d8582a4abe web/timeline: add local echoes and send status for messages 2024-10-12 18:15:52 +03:00
Tulir Asokan
8a16b46023 pre-commit: use more explicit path 2024-10-12 16:39:04 +03:00
Tulir Asokan
88919a2667 pre-commit: add tsc and eslint hooks 2024-10-12 16:30:05 +03:00
Tulir Asokan
2b6c00fa04 web/roomlist: add sender name to previews 2024-10-12 16:10:10 +03:00
Tulir Asokan
a7caf8a71f web/timeline: only omit sender profile for messages within 15 minutes 2024-10-12 15:47:51 +03:00
Tulir Asokan
31d4164e51 web/composer: add support for multiline input 2024-10-12 15:40:53 +03:00
Tulir Asokan
c5aac00985 web/roomview: split composer into separate component 2024-10-12 15:40:53 +03:00
Tulir Asokan
3b8767d504 web/timeline: add support for spoilers 2024-10-12 15:40:53 +03:00
Tulir Asokan
989b0fa0e5 web: add support for sending markdown and rainbows 2024-10-12 15:15:34 +03:00
Tulir Asokan
3ded0f4eb9 web/timeline: omit profile on consecutive messages from same sender 2024-10-12 13:44:39 +03:00
Tulir Asokan
cba5dbd912 web: fix lint issues 2024-10-12 13:21:57 +03:00
Tulir Asokan
5e0b8fc089 web/roomlist: highlight active room 2024-10-12 13:20:35 +03:00
Tulir Asokan
dcb2b61435 web/roomview: include avatar and name in header 2024-10-12 13:18:22 +03:00
Tulir Asokan
821701dec6 web: support rendering edits and refactor timeline updates 2024-10-12 13:07:59 +03:00
Tulir Asokan
757c1b444e websocket: disconnect if no data received in a minute 2024-10-12 12:14:57 +03:00
Tulir Asokan
6bb1d4477c web/lightbox: add control buttons 2024-10-12 00:31:01 +03:00
Tulir Asokan
26346df920 web/timeline: fix using filename as image alt 2024-10-12 00:29:40 +03:00
Tulir Asokan
10dd28bfda web/timeline: add title for message timestamp 2024-10-12 00:29:40 +03:00
Tulir Asokan
15b7380b29 web/types: split in-memory and wire event types 2024-10-12 00:29:40 +03:00
Tulir Asokan
7afa2d48c4 server: add authentication 2024-10-12 00:29:40 +03:00
Tulir Asokan
efbf75dad8 server: use exhttp for applying middlewares 2024-10-11 20:12:03 +03:00
Tulir Asokan
704fe45eb9 media: use cache directory for temp files 2024-10-11 20:09:51 +03:00
Tulir Asokan
420a7dab4e web/timeline: cache sanitized html 2024-10-11 00:05:15 +03:00
Tulir Asokan
f3bbb4c98e web/timeline: make code blocks scrollable 2024-10-11 00:05:07 +03:00
Tulir Asokan
2018952151 web/timeline: use grid instead of flex for individual events 2024-10-10 23:39:05 +03:00
Tulir Asokan
e6f0fc593c web/app: remove debug log 2024-10-10 23:38:49 +03:00
Tulir Asokan
842c8a593a web/timeline: add support for rendering captions 2024-10-10 23:20:10 +03:00
Tulir Asokan
45d5d2a1a5 web/timeline: improve formatted message styles 2024-10-10 22:35:52 +03:00
Tulir Asokan
c52c9029a7 web/timeline: lazy-load images and avatars 2024-10-10 22:19:14 +03:00
Tulir Asokan
67c9060e85 dependencies: update mautrix-go 2024-10-10 22:03:37 +03:00
Tulir Asokan
26808d557c web/timeline: include sender avatar and timestamp 2024-10-10 22:03:37 +03:00
Tulir Asokan
d428a26b0a web/roomview: autofocus input box 2024-10-10 21:50:14 +03:00
Tulir Asokan
543c3bcc25 web/timeline: add vertical align for custom emojis 2024-10-10 21:36:54 +03:00
Tulir Asokan
63268a0ccb web: add lightbox for viewing images 2024-10-10 21:36:42 +03:00
Tulir Asokan
33f67b65a8 web/timeline: ensure images don't change size when loaded 2024-10-10 20:55:15 +03:00
Tulir Asokan
947ce07d1f web: load room state when switching to room 2024-10-10 02:40:57 +03:00
Tulir Asokan
097caa7717 web: use useCallback instead of useMemo 2024-10-10 02:30:40 +03:00
Tulir Asokan
0dc278523a web/eslint: add better import order rules 2024-10-10 02:03:01 +03:00
Tulir Asokan
09ca63742f web/timeline: render sender displayname 2024-10-10 01:39:48 +03:00
Tulir Asokan
3065f7363c web/types: split standard matrix types to separate file 2024-10-10 01:25:01 +03:00
Tulir Asokan
dd6c7b4822 web/timeline: align messages to bottom when the screen isn't full 2024-10-10 01:05:11 +03:00
Tulir Asokan
8d669902d9 web/timeline: keep scroll position when loading history 2024-10-10 00:56:49 +03:00
Tulir Asokan
39ed3956f8 web/roomlist: lazy-load avatars 2024-10-10 00:08:50 +03:00
Tulir Asokan
1942fc464d web/statestore: flip decrypted content fields 2024-10-10 00:07:38 +03:00
Tulir Asokan
ca13912b07 web/roomview: scroll to bottom on new message 2024-10-09 22:00:20 +03:00
Tulir Asokan
3f2ca03128 web/roomview: clear input after sending 2024-10-09 21:27:48 +03:00
Tulir Asokan
b23f2913e0 ci: don't run lint on go 1.22 2024-10-09 21:27:39 +03:00
Tulir Asokan
0080981512 web/roomview: add support for sending messages 2024-10-09 21:24:07 +03:00
Tulir Asokan
c1eae98384 web: store current room state 2024-10-09 02:17:14 +03:00
Tulir Asokan
7831ec5d62 web: reorganize RPC client inheritance 2024-10-09 01:43:09 +03:00
Tulir Asokan
c048eedabe web: improve message rendering and move things around 2024-10-09 00:29:59 +03:00
Tulir Asokan
23478caa6e media: cache errors 2024-10-08 23:47:59 +03:00
Tulir Asokan
da675fb578 web: more file reorganization 2024-10-08 00:42:44 +03:00
Tulir Asokan
929bfbc882 web/roomview: add history pagination button 2024-10-07 22:18:17 +03:00
Tulir Asokan
05ef27cef5 ci: maybe fix eslint step 2024-10-07 20:39:39 +03:00
Tulir Asokan
3a68ec73f2 web: use helper instead of subscribing to event manually 2024-10-07 02:01:57 +03:00
Tulir Asokan
758e3e9086 ci: run eslint 2024-10-07 01:39:14 +03:00
Tulir Asokan
0cb2fb88cb web/eslint: add import sorting 2024-10-07 01:26:13 +03:00
Tulir Asokan
40b66f3057 web/all: reorganize files 2024-10-07 01:20:22 +03:00
Tulir Asokan
243ba51002 ci: update pre-commit hooks 2024-10-06 23:15:44 +03:00
Tulir Asokan
c10fd67bf2 ci: remove extra variable 2024-10-06 22:59:52 +03:00
Tulir Asokan
5693d431c2 ci: fix install command 2024-10-06 22:57:37 +03:00
Tulir Asokan
5284220e6c ci: fix dependency name 2024-10-06 22:54:19 +03:00
Tulir Asokan
3b2ae2d625 ci: build frontend 2024-10-06 22:52:15 +03:00
Tulir Asokan
1a359f9793 web: init 2024-10-06 21:45:46 +03:00
Tulir Asokan
4767def4b5 all: delete old code 2024-10-04 17:25:25 +03:00
Tulir Asokan
e6a2c3ff85 Remove unused newline toggle. Fixes #434 2024-07-27 12:05:58 +03:00
Tulir Asokan
4616f33d50 Bump version to 0.3.1 2024-07-16 11:06:27 +03:00
Tulir Asokan
be2842c551 Update dependencies 2024-07-13 19:39:24 +03:00
Tulir Asokan
a491128241 Also remove macOS universal CI step 2024-07-12 19:08:43 +03:00
Tulir Asokan
06d31f0e66 Remove macOS amd64 builds and update go-sqlite3 2024-07-12 19:06:34 +03:00
Tulir Asokan
bf922e4b1b Add proxy for providing authenticated download links 2024-07-12 18:59:59 +03:00
Tulir Asokan
3b2f1c79b9 Update mautrix-go for authenticated downloads 2024-07-10 11:45:52 +03:00
Tulir Asokan
09a9279558
Merge pull request #430 from nileshpatra/fix-debug-log-and-dir
Fix debug dir on linux and disable logging by default
2023-07-10 23:02:01 +03:00
Nilesh Patra
2b36ee3737 fix xdg-update-dir and enable prettypanic by default 2023-07-10 01:31:18 +05:30
Nilesh Patra
802d6afc55 Disable logging by default, start logging onlu if DEBUG is set to 1 2023-07-06 18:26:08 +00:00
Nilesh Patra
3c53798634 Set debug log dir to ~/.local/state/gomuks on linux 2023-07-06 18:25:33 +00:00
Tulir Asokan
b3f0410003
Merge pull request #428 from nileshpatra/diff-tmpdir
Make debug dir specific to username to ease off multi user logins
2023-07-01 14:34:59 +03:00
Tulir Asokan
253b47b076
Fix lint issue 2023-07-01 14:32:48 +03:00
Nilesh Patra
2a242c8f26 Make debug dir specific to username to ease off multi user logins 2023-07-01 14:35:25 +05:30
Tulir Asokan
e7ebb9745d Fix linux/arm64 build job tags 2023-06-24 01:38:32 +03:00
Tulir Asokan
373139c7bf Update Go version used for linting 2023-04-04 22:30:52 +03:00
Tulir Asokan
22900d8f8a Add command to manage power levels 2023-04-04 22:25:37 +03:00
Tulir Asokan
7e738485ee Don't sprintf with no arguments in Command.Reply 2023-04-04 22:09:29 +03:00
Tulir Asokan
099006c9c3 Don't read NotifySpecified in push rules 2023-04-04 22:09:25 +03:00
Tulir Asokan
2751b186fa Add safety for negative indexes in HTML renderer 2023-01-15 15:57:34 +02:00
Tulir Asokan
d6c08dc134 Update mautrix-go and remove duplicate function 2023-01-15 15:48:12 +02:00
Tulir Asokan
c0036f391b Build arm64 binaries natively 2022-11-30 12:31:49 +02:00
Tulir Asokan
68e9d6e981 Fix showing some types of errors in login screen
Double fixes #402
2022-11-21 22:45:28 +02:00
Tulir Asokan
6aaeb8c244 Check spec versions supported by homeserver. Fixes #402 2022-11-21 22:42:42 +02:00
Tulir Asokan
5aa494dc5e Bump version to 0.3.0 2022-11-19 17:24:57 +02:00
Tulir Asokan
c871a0c4b5 Update mauview and changelog 2022-11-19 17:21:07 +02:00
Tulir Asokan
14b9d30380 Merge remote-tracking branch 'n-peugnet/black-on-white-colors' 2022-11-19 17:15:04 +02:00
Tulir Asokan
a5b5468238 Log right before exiting process 2022-11-19 17:14:31 +02:00
Nicolas Peugnet
8450651a03 Better colors with black on white themes
Added ColorDefault to more places by default and override some other
specific places with white for better contrast.

This should not affect White on black themes.
2022-11-15 20:14:36 +01:00
Tulir Asokan
5db151d90f Update more dependencies 2022-11-13 17:59:11 +02:00
Tulir Asokan
d8f229c102 Update changelog 2022-11-13 15:45:46 +02:00
Tulir Asokan
f1528143aa Run gofmt 2022-11-13 15:40:10 +02:00
Tulir Asokan
0c1fc00f97 Fix another Hyperlink call 2022-11-13 15:34:21 +02:00
Tulir Asokan
886690f98f Update dependencies and Go version 2022-11-13 15:28:04 +02:00
Tulir Asokan
26b3c51053
Merge pull request #397 from n-peugnet/fix-message-redraw-first-reaction
Do not recalculate the buffer on first reaction
2022-11-09 18:00:59 +02:00
Tulir Asokan
94c483a0e1
Merge pull request #396 from n-peugnet/fix-baremessage-command-message
Fix baremessages command inverted message
2022-11-09 17:59:57 +02:00
Tulir Asokan
69e2dbd51c
Merge pull request #399 from n-peugnet/fix-breaks-plaintext-messages-with-url
Fix line breaks with URLs in plaintext only messages
2022-11-09 17:59:21 +02:00
Tulir Asokan
c741634733 Add hint for using SSO 2022-11-07 18:39:06 +02:00
Nicolas Peugnet
9fe2888017 Fix line breaks with URLs in plaintext only messages
By making sure that there is always only one container and no empty text
Entities.
2022-11-01 18:29:32 +01:00
Nicolas Peugnet
83da1df256 Do not recalculate the buffer on new reaction
Only the replacement is needed as the height is already correclty
calculated when drawn.

Fixes #283
2022-11-01 16:06:30 +01:00
Nicolas Peugnet
57fd8ed97c Fix baremessages command inverted message 2022-11-01 15:07:18 +01:00
Tulir Asokan
99a5c7caed
Merge pull request #387 from n-peugnet/mangled-lines
Fix mangled newlines in some code blocks
2022-10-21 23:15:25 +03:00
n-peugnet
1cff17a857 Fix codeblocks doubled newlines in plaintext mode 2022-10-19 13:53:22 +02:00
n-peugnet
58c20698d6 Fix mangled newlines in some code blocks
Because some tokens can contain newlines and not only comment tokens, I
removed the comments specific code to handle newlines in a more generic
way.
2022-10-19 13:53:21 +02:00
Tulir Asokan
71f16b797f
Merge pull request #388 from n-peugnet/newlines-nohtml
Preserve newlines in plain text messages
2022-10-19 14:33:22 +03:00
n-peugnet
77b3fffba1 s/Html/HTML/g 2022-10-19 13:30:16 +02:00
Tulir Asokan
1b097337df
Merge pull request #391 from n-peugnet/fix-add-reaction-increment
Fix redraw of incremented reaction counts
2022-10-18 14:06:52 +03:00
Tulir Asokan
ae10e8ebd7
Merge pull request #386 from n-peugnet/lexer-fallback
Fallback to "plaintext" lexer in codeblock parser
2022-10-18 13:56:23 +03:00
n-peugnet
8c3c4d31f1 Fix redraw of incremented reaction counts 2022-10-16 19:29:33 +02:00
n-peugnet
bfc7dd5196 Preserve newlines in plain text message 2022-10-10 20:30:13 +02:00
n-peugnet
82cf2ee816 Fallback to "plaintext" lexer in codeblock parser
This avoids "malformed message" in the timeline when the lexer for a
given language is not found.
2022-10-10 12:36:51 +02:00
Tulir Asokan
75471c3242 Add missing flags 2022-06-01 20:45:06 +03:00
Tulir Asokan
6479ff2e34 Add option to disable clearing screen
Somewhat hacky and might cause other issues, but should fix #365
2022-04-25 00:25:49 +03:00
Tulir Asokan
b4101f5518 Use runewidth fork to override variation selector width 2022-04-24 23:55:09 +03:00
Tulir Asokan
f00d61c7f0 Adjust unknown command message 2022-04-19 12:02:03 +03:00
Tulir Asokan
030c0c6ec5 Fix rendering empty/malformed messages 2022-04-19 12:01:56 +03:00
Tulir Asokan
1e6174f828 Add test panic command 2022-04-17 23:55:23 +03:00
Tulir Asokan
fa79dc0ca1 Update mauview
Fixes #367
2022-04-17 23:55:18 +03:00
Tulir Asokan
c45a66bbf9 Add more details to --version 2022-04-17 21:21:10 +03:00
Tulir Asokan
b755302b93 Update mautrix-go to switch to /v3 paths 2022-04-17 13:16:47 +03:00
Tulir Asokan
ebd0e2dabb Enable inline URLs by default on some other terminals 2022-04-16 20:14:10 +03:00
Tulir Asokan
b6fba5230a Enable inline URLs by default on VTE terminals 2022-04-16 19:59:34 +03:00
Tulir Asokan
f1d720e2dc Add /cs and /ssss to help 2022-04-16 19:37:24 +03:00
Tulir Asokan
b3e989ee0f Use xurls instead of custom regex for finding links 2022-04-16 19:26:24 +03:00
Tulir Asokan
a8660c7137 Merge remote-tracking branch 'n-peugnet/display-inline-code' 2022-04-16 19:26:08 +03:00
n-peugnet
0da7d78138 Display inline code in messages
The Textcolor is also set to make sure it is readable
with black on white themes
2022-04-16 17:53:47 +02:00
Tulir Asokan
f0d78798f3 Update README.md 2022-04-16 00:49:02 +03:00
Tulir Asokan
e08f23ba65 Only linkify text if inline URLs are enabled 2022-04-15 23:44:59 +03:00
Tulir Asokan
ebe3fcc33c Re-add function for service messages 2022-04-15 23:31:48 +03:00
Tulir Asokan
66233721a2 Get rid of special-cased plaintext rendering. Fixes #273 2022-04-15 23:28:23 +03:00
Tulir Asokan
7bf6785689 Linkify links in HTML messages too 2022-04-15 23:25:12 +03:00
Tulir Asokan
0fb06067ae Update changelog again 2022-04-15 22:58:48 +03:00
Tulir Asokan
0e033edce1 Update changelog 2022-04-15 22:58:12 +03:00
Tulir Asokan
8752b3e848 Linkify links in plaintext messages 2022-04-15 22:51:07 +03:00
Tulir Asokan
fa7a4d8320 Fix BaseEntity.String() 2022-04-15 22:36:06 +03:00
Tulir Asokan
6414b8bd13 Use inline link for file download URL 2022-04-15 22:30:07 +03:00
Tulir Asokan
404a617670 Update mauview 2022-04-15 22:29:58 +03:00
Tulir Asokan
b9bcfb24ef Open graphical file picker if no path is provided for /upload 2022-04-15 22:14:57 +03:00
Tulir Asokan
97491eb6c0 Add support for inline URLs
Fixes #71
Fixes #168
2022-04-15 22:09:15 +03:00
Tulir Asokan
98fe235f22 Update mauview to unbreak plaintext mode 2022-04-15 21:17:43 +03:00
Tulir Asokan
c2a3940e41 Allow copying any type of message. Fixes #276 2022-04-15 20:44:14 +03:00
Tulir Asokan
e46694a33f Ignore newlines in HTML completely again 2022-04-15 20:29:23 +03:00
Tulir Asokan
5674f076be Add support for sending spoilers 2022-04-15 20:28:58 +03:00
Tulir Asokan
a5bdba204e Add support for rendering spoilers. Fixes #331 2022-04-15 15:14:22 +03:00
Tulir Asokan
751a158fbf Parse HTML when editing messages. Fixes #301 2022-04-15 14:12:01 +03:00
Tulir Asokan
4136d6c6b0 Update mauview 2022-04-15 14:03:40 +03:00
Tulir Asokan
1b4aa60114 Add slightly hacky workaround to fix #316 2022-04-15 14:03:08 +03:00
Tulir Asokan
1ea20b6df7 Don't panic if a non-critical file disappears. Fixes #315 2022-04-15 13:34:31 +03:00
Tulir Asokan
a1ddad24c4 Add full changelog in a file 2022-04-15 13:28:24 +03:00
Tulir Asokan
7425bc25d9 Add lint to CI and pre-commit 2022-04-15 13:13:50 +03:00
Adam
c9633c095d
Add page up / down actions for Ctrl+P / Ctrl+N (#350)
Helpful to those who like these keys in other applications or who do not have pgup/pgdown keys.
2022-04-15 13:12:21 +03:00
Tulir Asokan
89d5b0aa7b Merge remote-tracking branch 'n-peugnet/toggle-autocompletions' 2022-04-15 13:10:14 +03:00
Tulir Asokan
18aeb8ba9d Improve removing unnecessary whitespace in HTML 2022-04-15 12:58:27 +03:00
Tulir Asokan
899bdbc705 Update tcell 2022-04-15 12:53:09 +03:00
n-peugnet
edaed2bf65 Add command autocompletion for toggle 2022-04-11 00:05:37 +02:00
Tulir Asokan
a7562a068a Add variation selectors and trim spaces in reactions 2022-03-30 15:27:05 +03:00
tleb
31e87b584f
Allow sending emoji using plaintext shortcodes in /react (#354) 2022-03-30 15:21:26 +03:00
Tulir Asokan
2a07ff6781 Maybe fix date change messages. Fixes #277 2022-03-07 17:53:01 +02:00
Tulir Asokan
ae34776631 Add up/down arrow key support to fuzzy search. Closes #340 2022-03-06 22:47:34 +02:00
Tulir Asokan
b5543736f6 Mention /toggle without arguments in help page
Closes #347
Fixes #341
2022-03-06 22:44:32 +02:00
Tulir Asokan
daf0067d53 Remove ctrl+a from default keybindings and fix esc 2022-03-06 22:44:02 +02:00
Tulir Asokan
686f7025cb Add room name to topic bar if room list is hidden. Closes #317 2022-03-06 22:34:24 +02:00
Tulir Asokan
073739b79b Enable default keybindings by default 2022-03-06 22:30:42 +02:00
Tulir Asokan
9a0a1636af Update dependencies 2022-03-06 22:30:41 +02:00
Tulir Asokan
f652501fa2 Merge remote-tracking branch '3nprob/keyconfig'
Closes #325
2022-03-06 22:30:35 +02:00
Tulir Asokan
c1873e9214 Merge remote-tracking branch 'ehaupt/patch-1' 2022-03-06 22:14:45 +02:00
Tulir Asokan
b2ca5ed37e Add support for editing message in external editor
Closes #313
Fixes #311
2022-03-06 22:13:57 +02:00
Emanuel Haupt
963acfe7c5
Add a repology badge
It would be nice to have a [repology](https://repology.org) badge showing the number of packages. There are even more [badges](https://repology.org/project/gomuks/badges) available.
2022-02-18 21:32:47 +01:00
Tulir Asokan
6525f9ec66 Add rainbownotice command 2022-02-16 19:38:10 +02:00
Tulir Asokan
172d18470f Fix order of reading environment variables 2022-02-16 19:37:11 +02:00
3nprob
892f4fc871 add default keybindings sample 2021-12-08 01:27:27 +09:00
3nprob
5e3ccb8c53 Configurable keybindings 2021-12-08 01:26:56 +09:00
3nprob
4e8c7b6759 add cbind dependency 2021-12-08 01:25:56 +09:00
Tulir Asokan
baddce264f Update mauview and runewidth 2021-12-07 14:12:44 +02:00
Tulir Asokan
98db812df1 Add config flags for backspace behavior. Fixes #322 2021-11-11 00:26:53 +02:00
Tulir Asokan
e3094f4f80 Update mautrix-go 2021-09-30 18:58:38 +03:00
445 changed files with 57850 additions and 14775 deletions

View file

@ -1,17 +0,0 @@
version: "2"
checks:
method-count:
config:
threshold: 50
engines:
golint:
enabled: true
checks:
GoLint/Comments/DocComments:
enabled: false
gofmt:
enabled: true
govet:
enabled: true

View file

@ -7,6 +7,7 @@ end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
max_line_length = 120
[.gitlab-ci.yml] [.gitlab-ci.yml]
indent_size = 2 indent_size = 2

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

42
.github/workflows/go.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: Go
on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.23", "1.24"]
name: Lint Go ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }}
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install libolm-dev libolm3 libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
export PATH="$HOME/go/bin:$PATH"
mkdir -p web/dist
touch web/dist/empty
- name: Build
run: go build -v ./...
- name: Lint
uses: pre-commit/action@v3.0.1
- name: Test
run: go test -v ./...

20
.github/workflows/js.yml vendored Normal file
View file

@ -0,0 +1,20 @@
name: JS
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web
name: Lint JS
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci --include=dev
- name: Run ESLint
run: npm run lint

29
.github/workflows/stale.yml vendored Normal file
View file

@ -0,0 +1,29 @@
name: 'Lock old issues'
on:
schedule:
- cron: '0 13 * * *'
workflow_dispatch:
permissions:
issues: write
# pull-requests: write
# discussions: write
concurrency:
group: lock-threads
jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
id: lock
with:
issue-inactive-days: 90
process-only: issues
- name: Log processed threads
run: |
if [ '${{ steps.lock.outputs.issues }}' ]; then
echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"'
fi

6
.gitignore vendored
View file

@ -1,10 +1,14 @@
.idea/ .idea/
target/ target/
.tmp/ .tmp/
gomuks /gomuks
start
run
*.exe *.exe
*.deb *.deb
coverage.out coverage.out
coverage.html coverage.html
deb/usr deb/usr
*.prof *.prof
*.db*
*.log

View file

@ -1,6 +1,8 @@
stages: stages:
- frontend
- build - build
- package - build desktop
- docker
default: default:
before_script: before_script:
@ -11,51 +13,87 @@ cache:
paths: paths:
- .cache - .cache
variables:
GOTOOLCHAIN: local
frontend:
image: node:22-alpine
stage: frontend
cache:
paths:
- web/node_modules
script:
- cd web
- npm install --include=dev
- npm run build
artifacts:
paths:
- web/dist
expire_in: 1 hour
.build-linux: &build-linux .build-linux: &build-linux
stage: build stage: build
cache:
paths:
- .cache
before_script:
- mkdir -p .cache
- export GOPATH="$CI_PROJECT_DIR/.cache"
- export GOCACHE="$CI_PROJECT_DIR/.cache/build"
- export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- export GO_LDFLAGS="-s -w -linkmode external -extldflags -static -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
script: script:
- go build -ldflags "-linkmode external -extldflags -static" -o gomuks - go build -ldflags "$GO_LDFLAGS" ./cmd/gomuks
artifacts: artifacts:
paths: paths:
- gomuks - gomuks
dependencies:
- frontend
needs:
- frontend
.build-docker: &build-docker
image: docker:stable
stage: docker
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker pull $CI_REGISTRY_IMAGE:latest || true
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-$DOCKER_ARCH . --file Dockerfile.ci
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-$DOCKER_ARCH
after_script:
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-$DOCKER_ARCH
linux/amd64: linux/amd64:
<<: *build-linux <<: *build-linux
image: dock.mau.dev/tulir/gomuks-build-docker:linux-amd64 image: dock.mau.dev/tulir/gomuks-build-docker:linux-amd64
tags:
- linux
- amd64
linux/arm: linux/arm:
<<: *build-linux <<: *build-linux
image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm
tags:
- linux
- amd64
linux/arm64: linux/arm64:
<<: *build-linux <<: *build-linux
image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm64 image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm64-native
tags:
- linux
- arm64
windows/amd64: windows/amd64:
<<: *build-linux
image: dock.mau.dev/tulir/gomuks-build-docker:windows-amd64 image: dock.mau.dev/tulir/gomuks-build-docker:windows-amd64
stage: build
script:
- go build -ldflags "-linkmode external -extldflags -static" -o gomuks.exe
artifacts: artifacts:
paths: paths:
- gomuks.exe - gomuks.exe
macos/amd64:
stage: build
tags: tags:
- macos - linux
- amd64 - amd64
before_script: []
script:
- mkdir gomuks-macos-amd64
- go build -o gomuks-macos-amd64/gomuks
- install_name_tool -change /usr/local/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks-macos-amd64/gomuks
- install_name_tool -add_rpath @executable_path gomuks-macos-amd64/gomuks
- install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks-macos-amd64/gomuks
- cp /usr/local/opt/libolm/lib/libolm.3.dylib gomuks-macos-amd64/
artifacts:
paths:
- gomuks-macos-amd64
macos/arm64: macos/arm64:
stage: build stage: build
@ -63,54 +101,156 @@ macos/arm64:
- macos - macos
- arm64 - arm64
before_script: before_script:
- export LIBRARY_PATH=/opt/homebrew/lib
- export CPATH=/opt/homebrew/include
- export PATH=/opt/homebrew/bin:$PATH - export PATH=/opt/homebrew/bin:$PATH
- export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- export GO_LDFLAGS="-X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
- export LIBRARY_PATH=$(brew --prefix)/lib
- export CPATH=$(brew --prefix)/include
script: script:
- mkdir gomuks-macos-arm64 - go build -ldflags "$GO_LDFLAGS" -o gomuks ./cmd/gomuks
- go build -o gomuks-macos-arm64/gomuks - install_name_tool -change $(brew --prefix)/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks
- install_name_tool -change /opt/homebrew/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks-macos-arm64/gomuks - install_name_tool -add_rpath @executable_path gomuks
- install_name_tool -add_rpath @executable_path gomuks-macos-arm64/gomuks - install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib gomuks
- install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib gomuks-macos-arm64/gomuks - install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks
- install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks-macos-arm64/gomuks - cp $(brew --prefix)/opt/libolm/lib/libolm.3.dylib .
- cp /opt/homebrew/opt/libolm/lib/libolm.3.dylib gomuks-macos-arm64/
artifacts: artifacts:
paths: paths:
- gomuks-macos-arm64
macos/universal:
stage: package
tags:
- macos
dependencies:
- macos/amd64
- macos/arm64
needs:
- macos/amd64
- macos/arm64
variables:
GIT_STRATEGY: none
script:
- lipo -create -output libolm.3.dylib gomuks-macos-arm64/libolm.3.dylib gomuks-macos-amd64/libolm.3.dylib
- lipo -create -output gomuks gomuks-macos-arm64/gomuks gomuks-macos-amd64/gomuks
artifacts:
name: gomuks-macos-universal
paths:
- libolm.3.dylib
- gomuks - gomuks
- libolm.3.dylib
dependencies:
- frontend
needs:
- frontend
debian: docker/amd64:
image: debian <<: *build-docker
stage: package tags:
- linux
- amd64
dependencies: dependencies:
- linux/amd64 - linux/amd64
only: needs:
- tags - linux/amd64
variables:
DOCKER_ARCH: amd64
docker/arm64:
<<: *build-docker
tags:
- linux
- arm64
dependencies:
- linux/arm64
needs:
- linux/arm64
variables:
DOCKER_ARCH: arm64
docker/manifest:
stage: docker
variables:
GIT_STRATEGY: none
before_script:
- "mkdir -p $HOME/.docker && echo '{\"experimental\": \"enabled\"}' > $HOME/.docker/config.json"
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
needs:
- docker/amd64
- docker/arm64
script: script:
- mkdir -p deb/usr/bin - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
- cp gomuks deb/usr/bin/gomuks - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
- chmod -R -s deb/DEBIAN && chmod -R 0755 deb/DEBIAN - |
- dpkg-deb --build deb gomuks.deb if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
export MANIFEST_NAME="$CI_REGISTRY_IMAGE:latest"
else
export MANIFEST_NAME="$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME//\//_}"
fi
docker manifest create $MANIFEST_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
docker manifest push $MANIFEST_NAME
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
.build-desktop: &build-desktop
stage: build desktop
cache:
paths:
- .cache
before_script:
- mkdir -p .cache
- export GOPATH="$CI_PROJECT_DIR/.cache"
- export GOCACHE="$CI_PROJECT_DIR/.cache/build"
script:
- cd desktop
- wails3 task $PLATFORM:package
- ls bin
artifacts: artifacts:
paths: paths:
- gomuks.deb - desktop/bin/*
dependencies:
- frontend
needs:
- frontend
desktop/linux/amd64:
<<: *build-desktop
image: dock.mau.dev/tulir/gomuks-build-docker/wails:linux-amd64
variables:
PLATFORM: linux
after_script:
- mv desktop/bin/gomuks-desktop .
- mv desktop/build/nfpm/bin/gomuks-desktop.deb .
artifacts:
paths:
- gomuks-desktop
- gomuks-desktop.deb
tags:
- linux
- amd64
desktop/linux/arm64:
<<: *build-desktop
image: dock.mau.dev/tulir/gomuks-build-docker/wails:linux-arm64-native
variables:
PLATFORM: linux
after_script:
- mv desktop/bin/gomuks-desktop .
- mv desktop/build/nfpm/bin/gomuks-desktop.deb .
artifacts:
paths:
- gomuks-desktop
- gomuks-desktop.deb
tags:
- linux
- arm64
desktop/windows/amd64:
<<: *build-desktop
image: dock.mau.dev/tulir/gomuks-build-docker/wails:windows-amd64
after_script:
- mv desktop/bin/gomuks-desktop.exe .
artifacts:
paths:
- gomuks-desktop.exe
variables:
PLATFORM: windows
desktop/macos/arm64:
<<: *build-desktop
cache: {}
before_script:
- export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH
- export LIBRARY_PATH=$(brew --prefix)/lib
- export CPATH=$(brew --prefix)/include
after_script:
- hdiutil create -srcFolder ./desktop/bin/gomuks-desktop.app/ -o ./gomuks-desktop.dmg
- codesign -s - --timestamp -i fi.mau.gomuks.desktop.mac gomuks-desktop.dmg
- mv desktop/bin/gomuks-desktop .
artifacts:
paths:
- gomuks-desktop
# TODO generate proper dmgs
#- gomuks-desktop.dmg
variables:
PLATFORM: darwin
tags:
- macos
- arm64

39
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,39 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.1
hooks:
- id: go-imports-repo
args:
- "-local"
- "go.mau.fi/gomuks"
- "-w"
- id: go-mod-tidy
- id: go-vet-repo-mod
- id: go-staticcheck-repo-mod
- repo: https://github.com/beeper/pre-commit-go
rev: v0.4.2
hooks:
- id: prevent-literal-http-methods
- repo: local
hooks:
- id: eslint
name: eslint
entry: ./.pre-commit-eslint.sh
language: script
types_or: [ts, tsx]
- id: typescript
name: typescript
entry: ./.pre-commit-tsc.sh
language: script
types_or: [ts, tsx]

6
.pre-commit-eslint.sh Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
cd web > /dev/null
if [[ -f "./node_modules/.bin/eslint" ]]; then
ARGS=("$@")
./node_modules/.bin/eslint --fix ${ARGS[@]/#web\// }
fi

5
.pre-commit-tsc.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
cd web > /dev/null
if [[ -f "./node_modules/.bin/tsc" ]]; then
./node_modules/.bin/tsc --build --noEmit
fi

157
CHANGELOG.md Normal file
View file

@ -0,0 +1,157 @@
# v0.3.1 (2024-07-16)
* Bumped minimum Go version to 1.21.
* Added support for authenticated media.
* Added `/powerlevel` command for managing power levels.
* Disabled logging by default.
* Changed default log directory to `~/.local/state/gomuks` on Linux.
# v0.3.0 (2022-11-19)
* Bumped minimum Go version to 1.18.
* Switched from `/r0` to `/v3` paths everywhere.
* The new `v3` paths are implemented since Synapse 1.48, Dendrite 0.6.5,
and Conduit 0.4.0. Servers older than these are no longer supported.
* Added config flags for backspace behavior.
* Added `/rainbownotice` command to send a rainbow as a `m.notice` message.
* Added support for editing messages in an external editor.
* Added arrow key support for navigating results in fuzzy search.
* Added initial support for configurable keyboard shortcuts
(thanks to [@3nprob] in [#328]).
* Added support for shortcodes *without* tab-completion in `/react`
(thanks to [@tleb] in [#354]).
* Added background color to differentiate `inline code`
(thanks to [@n-peugnet] in [#361]).
* Added tab-completion support for `/toggle` options
(thanks to [@n-peugnet] in [#362]).
* Added initial support for rendering spoilers in messages.
* Added support for sending spoilers (with `||reason|spoiler||` or `||spoiler||`).
* Added support for inline links (limited terminal support; requires
`/toggle inlineurls`).
* Added graphical file picker for `/upload` when no path is provided
(requires `zenity`).
* Updated more places to use default/reverse colors instead of white/black to
better work on light themed terminals (thanks to [@n-peugnet] in [#401]).
* Fixed mentions being lost when editing messages.
* Fixed date change messages showing the wrong date.
* Fixed some whitespace in HTML being rendered even when it shouldn't.
* Fixed copying non-text messages with `/copy`.
* Fixed rendering code blocks with unknown languages
(thanks to [@n-peugnet] in [#386]).
* Fixed newlines not working in code blocks with certain syntax highlightings
(thanks to [@n-peugnet] in [#387]).
* Fixed rendering more than one reaction of the same type in a single message
(thanks to [@n-peugnet] in [#391]).
* Fixed line-wrapped messages getting corrupted when receiving a reaction
(thanks to [@n-peugnet] in [#397]).
[@3nprob]: https://github.com/3nprob
[@tleb]: https://github.com/tleb
[@n-peugnet]: https://github.com/n-peugnet
[#328]: https://github.com/tulir/gomuks/pull/328
[#354]: https://github.com/tulir/gomuks/pull/354
[#361]: https://github.com/tulir/gomuks/pull/361
[#362]: https://github.com/tulir/gomuks/pull/362
[#401]: https://github.com/tulir/gomuks/pull/401
# v0.2.4 (2021-09-21)
* Added `is_direct` flag when creating DMs (thanks to [@gsauthof] in [#261]).
* Added `newline` toggle for swapping enter and alt-enter behavior
(thanks to [@octeep] in [#270]).
* Added `timestamps` toggle for disabling timestamps in the UI
(thanks to [@lxea] in [#304]).
* Added support for getting custom download directory with `xdg-user-dir`.
* Added support for updating homeserver URL based on well-known data in
`/login` response.
* Updated some places to use default color instead of white to better work on
light themed terminals (thanks to [@zavok] in [#280]).
* Updated notification library to work on all unix-like systems with `notify-send`.
* Notification sounds will now work if either `paplay` or `ogg123` is available.
* Based on work by [@negatethis] (in [#298]) and [@begss] (in [#312]).
* Disabled logging request content for sensitive requests like `/login` and
cross-signing key uploads.
* Fixed caching state of rooms where the room ID contains slashes.
* Fixed index error in fuzzy search (thanks to [@Evidlo] in [#268]).
[@gsauthof]: https://github.com/gsauthof
[@octeep]: https://github.com/octeep
[@lxea]: https://github.com/lxea
[@zavok]: https://github.com/zavok
[@negatethis]: https://github.com/negatethis
[@begss]: https://github.com/begss
[@Evidlo]: https://github.com/Evidlo
[#261]: https://github.com/tulir/gomuks/pull/261
[#268]: https://github.com/tulir/gomuks/pull/268
[#270]: https://github.com/tulir/gomuks/pull/270
[#280]: https://github.com/tulir/gomuks/pull/280
[#298]: https://github.com/tulir/gomuks/pull/298
[#304]: https://github.com/tulir/gomuks/pull/304
[#312]: https://github.com/tulir/gomuks/pull/312
# v0.2.3 (2021-02-19)
* Switched crypto store to use SQLite to prevent it from getting corrupted all
the time.
* Added macOS builds (both x86 and arm64).
* Allowed password login to servers with both SSO and password login enabled.
# v0.2.2 (2021-01-06)
* Added some initial cross-signing/SSSS commands.
* Updated mautrix-go to fix Go 1.15.3+ compatibility.
* Fixed text selection panic caused by clipboard.
* Fixed incoming encryption state events not being detected.
* Fixed zombie processes left from opening files (thanks to [@Midek] in [#234]).
[@Midek]: https://github.com/Midek
[#234]: https://github.com/tulir/gomuks/pull/234
# v0.2.1 (2020-10-23)
* Moved help into a modal (partially done by [@wvffle] in [#223]).
* Fixed choosing a login flow when logging in.
* Fixed edits by different users than the original message sender being rendered.
* Fixed panic when rendering empty code block.
* Fixed panic in `/open` command (thanks to [@dec05eba] in [#226]).
* Fixed command autocompletion (thanks to [@wvffle] in [#222]).
[@dec05eba]: https://github.com/dec05eba
[#222]: https://github.com/tulir/gomuks/pull/222
[#223]: https://github.com/tulir/gomuks/pull/223
[#226]: https://github.com/tulir/gomuks/pull/226
# v0.2.0 (2020-09-04)
* Added interactive device verification support (only outgoing requests currently).
* Added option to show inline link target as text (thanks to [@r3k2] in [#189]).
* Added `/edit` command as an alternative to <kbd></kbd>/<kbd></kbd>.
* Added support for importing and exporting message decryption keys.
* Added command for uploading files (started by [@wvffle] in [#206]).
* Added parameter autocompletion for some commands (mostly the new crypto and
upload commands, but also `/download` and `/open`).
* Fixed autocompleting HTML pills when markdown is disabled.
* Fixed editing the same message many times.
* Fixed mangled comment newlines in code blocks (thanks to [@wvffle] in [#214]).
[@wvffle]: https://github.com/wvffle
[@r3k2]: https://github.com/r3k2
[#189]: https://github.com/tulir/gomuks/pull/189
[#206]: https://github.com/tulir/gomuks/pull/206
[#214]: https://github.com/tulir/gomuks/pull/214
# v0.1.2 (2020-06-24)
* Fixed panic when clicking <kbd>Shift</kbd>+<kbd>Tab</kbd> on the first item
of the fuzzy room search dialog.
* Fixed panic when rendering `m.room.canonical_alias` events with no
`prev_content`.
* Fixed rendering displayname changes.
# v0.1.1 (2020-06-24)
No changelog available.
# v0.1.0 (2020-05-10)
Initial release.

11
Dockerfile.ci Normal file
View file

@ -0,0 +1,11 @@
FROM alpine:3.21
RUN apk add --no-cache ca-certificates jq curl ffmpeg
ARG EXECUTABLE=./gomuks
COPY $EXECUTABLE /usr/bin/gomuks
VOLUME /data
WORKDIR /data
ENV GOMUKS_ROOT=/data
CMD ["/usr/bin/gomuks"]

View file

@ -2,17 +2,20 @@
![Languages](https://img.shields.io/github/languages/top/tulir/gomuks.svg) ![Languages](https://img.shields.io/github/languages/top/tulir/gomuks.svg)
[![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE) [![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases) [![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases)
[![GitLab CI](https://mau.dev/tulir/gomuks/badges/master/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines) [![GitLab CI](https://mau.dev/tulir/gomuks/badges/main/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines)
[![Maintainability](https://img.shields.io/codeclimate/maintainability/tulir/gomuks.svg)](https://codeclimate.com/github/tulir/gomuks)
![Chat Preview](chat-preview.png) A Matrix client written in Go using [mautrix](https://github.com/mautrix/go).
A terminal Matrix client written in Go using [mautrix](https://github.com/tulir/mautrix-go) and [mauview](https://github.com/tulir/mauview). This branch contains gomuks web. For legacy gomuks terminal, see the
[master branch](https://github.com/tulir/gomuks/tree/master). The new
version will get a terminal frontend in the future. See also:
<https://github.com/tulir/gomuks/issues/476>.
Basic usage is possible, but expect bugs and missing features. ## Sponsors
* [conduwuit](https://github.com/girlbossceo/conduwuit)
## Docs ## Documentation
For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/). For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/).
## Discussion ## Discussion
Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net) Matrix room: [#gomuks:gomuks.app](https://matrix.to/#/#gomuks:gomuks.app)

4
build.sh Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
go generate ./web
export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | head -n1 | awk '{ print $2 }')
go build -ldflags "-X go.mau.fi/gomuks/version.Tag=$(git describe --exact-match --tags 2>/dev/null) -X go.mau.fi/gomuks/version.Commit=$(git rev-parse HEAD) -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" ./cmd/gomuks "$@" || exit 2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

63
cmd/gomuks/main.go Normal file
View file

@ -0,0 +1,63 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"fmt"
"os"
"go.mau.fi/util/exhttp"
flag "maunium.net/go/mauflag"
"go.mau.fi/gomuks/pkg/gomuks"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/version"
"go.mau.fi/gomuks/web"
)
var wantHelp, _ = flag.MakeHelpFlag()
var wantVersion = flag.MakeFull("v", "version", "View gomuks version and quit.", "false").Bool()
func main() {
hicli.InitialDeviceDisplayName = "gomuks web"
exhttp.AutoAllowCORS = false
flag.SetHelpTitles(
"gomuks - A Matrix client written in Go.",
"gomuks [-hv]",
)
err := flag.Parse()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
flag.PrintHelp()
os.Exit(1)
} else if *wantHelp {
flag.PrintHelp()
os.Exit(0)
} else if *wantVersion {
fmt.Println(version.Description)
os.Exit(0)
}
gmx := gomuks.NewGomuks()
gmx.Version = version.Version
gmx.Commit = version.Commit
gmx.LinkifiedVersion = version.LinkifiedVersion
gmx.BuildTime = version.ParsedBuildTime
gmx.FrontendFS = web.Frontend
gmx.Run()
}

View file

@ -1,298 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package config
import (
"encoding/json"
"io/ioutil"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/matrix/rooms"
)
type AuthCache struct {
NextBatch string `yaml:"next_batch"`
FilterID string `yaml:"filter_id"`
FilterVersion int `yaml:"filter_version"`
InitialSyncDone bool `yaml:"initial_sync_done"`
}
type UserPreferences struct {
HideUserList bool `yaml:"hide_user_list"`
HideRoomList bool `yaml:"hide_room_list"`
HideTimestamp bool `yaml:"hide_timestamp"`
BareMessageView bool `yaml:"bare_message_view"`
DisableImages bool `yaml:"disable_images"`
DisableTypingNotifs bool `yaml:"disable_typing_notifs"`
DisableEmojis bool `yaml:"disable_emojis"`
DisableMarkdown bool `yaml:"disable_markdown"`
DisableHTML bool `yaml:"disable_html"`
DisableDownloads bool `yaml:"disable_downloads"`
DisableNotifications bool `yaml:"disable_notifications"`
DisableShowURLs bool `yaml:"disable_show_urls"`
AltEnterToSend bool `yaml:"alt_enter_to_send"`
}
// Config contains the main config of gomuks.
type Config struct {
UserID id.UserID `yaml:"mxid"`
DeviceID id.DeviceID `yaml:"device_id"`
AccessToken string `yaml:"access_token"`
HS string `yaml:"homeserver"`
RoomCacheSize int `yaml:"room_cache_size"`
RoomCacheAge int64 `yaml:"room_cache_age"`
NotifySound bool `yaml:"notify_sound"`
SendToVerifiedOnly bool `yaml:"send_to_verified_only"`
Dir string `yaml:"-"`
DataDir string `yaml:"data_dir"`
CacheDir string `yaml:"cache_dir"`
HistoryPath string `yaml:"history_path"`
RoomListPath string `yaml:"room_list_path"`
MediaDir string `yaml:"media_dir"`
DownloadDir string `yaml:"download_dir"`
StateDir string `yaml:"state_dir"`
Preferences UserPreferences `yaml:"-"`
AuthCache AuthCache `yaml:"-"`
Rooms *rooms.RoomCache `yaml:"-"`
PushRules *pushrules.PushRuleset `yaml:"-"`
nosave bool
}
// NewConfig creates a config that loads data from the given directory.
func NewConfig(configDir, dataDir, cacheDir, downloadDir string) *Config {
return &Config{
Dir: configDir,
DataDir: dataDir,
CacheDir: cacheDir,
DownloadDir: downloadDir,
HistoryPath: filepath.Join(cacheDir, "history.db"),
RoomListPath: filepath.Join(cacheDir, "rooms.gob.gz"),
StateDir: filepath.Join(cacheDir, "state"),
MediaDir: filepath.Join(cacheDir, "media"),
RoomCacheSize: 32,
RoomCacheAge: 1 * 60,
NotifySound: true,
SendToVerifiedOnly: false,
}
}
// Clear clears the session cache and removes all history.
func (config *Config) Clear() {
_ = os.Remove(config.HistoryPath)
_ = os.Remove(config.RoomListPath)
_ = os.RemoveAll(config.StateDir)
_ = os.RemoveAll(config.MediaDir)
_ = os.RemoveAll(config.CacheDir)
config.nosave = true
}
// ClearData clears non-temporary session data.
func (config *Config) ClearData() {
_ = os.RemoveAll(config.DataDir)
}
func (config *Config) CreateCacheDirs() {
_ = os.MkdirAll(config.CacheDir, 0700)
_ = os.MkdirAll(config.DataDir, 0700)
_ = os.MkdirAll(config.StateDir, 0700)
_ = os.MkdirAll(config.MediaDir, 0700)
}
func (config *Config) DeleteSession() {
config.AuthCache.NextBatch = ""
config.AuthCache.InitialSyncDone = false
config.AccessToken = ""
config.DeviceID = ""
config.Rooms = rooms.NewRoomCache(config.RoomListPath, config.StateDir, config.RoomCacheSize, config.RoomCacheAge, config.GetUserID)
config.PushRules = nil
config.ClearData()
config.Clear()
config.nosave = false
config.CreateCacheDirs()
}
func (config *Config) LoadAll() {
config.Load()
config.Rooms = rooms.NewRoomCache(config.RoomListPath, config.StateDir, config.RoomCacheSize, config.RoomCacheAge, config.GetUserID)
config.LoadAuthCache()
config.LoadPushRules()
config.LoadPreferences()
err := config.Rooms.LoadList()
if err != nil {
panic(err)
}
}
// Load loads the config from config.yaml in the directory given to the config struct.
func (config *Config) Load() {
config.load("config", config.Dir, "config.yaml", config)
config.CreateCacheDirs()
}
func (config *Config) SaveAll() {
config.Save()
config.SaveAuthCache()
config.SavePushRules()
config.SavePreferences()
err := config.Rooms.SaveList()
if err != nil {
panic(err)
}
config.Rooms.SaveLoadedRooms()
}
// Save saves this config to config.yaml in the directory given to the config struct.
func (config *Config) Save() {
config.save("config", config.Dir, "config.yaml", config)
}
func (config *Config) LoadPreferences() {
config.load("user preferences", config.CacheDir, "preferences.yaml", &config.Preferences)
}
func (config *Config) SavePreferences() {
config.save("user preferences", config.CacheDir, "preferences.yaml", &config.Preferences)
}
func (config *Config) LoadAuthCache() {
config.load("auth cache", config.CacheDir, "auth-cache.yaml", &config.AuthCache)
}
func (config *Config) SaveAuthCache() {
config.save("auth cache", config.CacheDir, "auth-cache.yaml", &config.AuthCache)
}
func (config *Config) LoadPushRules() {
config.load("push rules", config.CacheDir, "pushrules.json", &config.PushRules)
}
func (config *Config) SavePushRules() {
if config.PushRules == nil {
return
}
config.save("push rules", config.CacheDir, "pushrules.json", &config.PushRules)
}
func (config *Config) load(name, dir, file string, target interface{}) {
err := os.MkdirAll(dir, 0700)
if err != nil {
debug.Print("Failed to create", dir)
panic(err)
}
path := filepath.Join(dir, file)
data, err := ioutil.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return
}
debug.Print("Failed to read", name, "from", path)
panic(err)
}
if strings.HasSuffix(file, ".yaml") {
err = yaml.Unmarshal(data, target)
} else {
err = json.Unmarshal(data, target)
}
if err != nil {
debug.Print("Failed to parse", name, "at", path)
panic(err)
}
}
func (config *Config) save(name, dir, file string, source interface{}) {
if config.nosave {
return
}
err := os.MkdirAll(dir, 0700)
if err != nil {
debug.Print("Failed to create", dir)
panic(err)
}
var data []byte
if strings.HasSuffix(file, ".yaml") {
data, err = yaml.Marshal(source)
} else {
data, err = json.Marshal(source)
}
if err != nil {
debug.Print("Failed to marshal", name)
panic(err)
}
path := filepath.Join(dir, file)
err = ioutil.WriteFile(path, data, 0600)
if err != nil {
debug.Print("Failed to write", name, "to", path)
panic(err)
}
}
func (config *Config) GetUserID() id.UserID {
return config.UserID
}
const FilterVersion = 1
func (config *Config) SaveFilterID(_ id.UserID, filterID string) {
config.AuthCache.FilterID = filterID
config.AuthCache.FilterVersion = FilterVersion
config.SaveAuthCache()
}
func (config *Config) LoadFilterID(_ id.UserID) string {
if config.AuthCache.FilterVersion != FilterVersion {
return ""
}
return config.AuthCache.FilterID
}
func (config *Config) SaveNextBatch(_ id.UserID, nextBatch string) {
config.AuthCache.NextBatch = nextBatch
config.SaveAuthCache()
}
func (config *Config) LoadNextBatch(_ id.UserID) string {
return config.AuthCache.NextBatch
}
func (config *Config) SaveRoom(_ *mautrix.Room) {
panic("SaveRoom is not supported")
}
func (config *Config) LoadRoom(_ id.RoomID) *mautrix.Room {
panic("LoadRoom is not supported")
}

View file

@ -1,2 +0,0 @@
// Package config contains the wrappers for gomuks configurations and sessions.
package config

View file

@ -1,7 +0,0 @@
Package: gomuks
Version: 0.2.4-1
Section: net
Priority: optional
Architecture: amd64
Maintainer: Tulir Asokan <tulir@maunium.net>
Description: A terminal based Matrix client written in Go.

View file

@ -1,156 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package debug
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"runtime/debug"
"time"
"github.com/sasha-s/go-deadlock"
)
var writer io.Writer
var RecoverPrettyPanic bool
var DeadlockDetection bool
var WriteLogs bool
var OnRecover func()
var LogDirectory = filepath.Join(os.TempDir(), "gomuks")
func Initialize() {
err := os.MkdirAll(LogDirectory, 0750)
if err != nil {
RecoverPrettyPanic = false
DeadlockDetection = false
WriteLogs = false
return
}
if WriteLogs {
writer, err = os.OpenFile(filepath.Join(LogDirectory, "debug.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0640)
if err != nil {
panic(err)
}
_, _ = fmt.Fprintf(writer, "======================= Debug init @ %s =======================\n", time.Now().Format("2006-01-02 15:04:05"))
}
if DeadlockDetection {
deadlocks, err := os.OpenFile(filepath.Join(LogDirectory, "deadlock.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0640)
if err != nil {
panic(err)
}
deadlock.Opts.LogBuf = deadlocks
deadlock.Opts.OnPotentialDeadlock = func() {
if OnRecover != nil {
OnRecover()
}
_, _ = fmt.Fprintf(os.Stderr, "Potential deadlock detected. See %s/deadlock.log for more information.", LogDirectory)
os.Exit(88)
}
_, err = fmt.Fprintf(deadlocks, "======================= Debug init @ %s =======================\n", time.Now().Format("2006-01-02 15:04:05"))
if err != nil {
panic(err)
}
} else {
deadlock.Opts.Disable = true
}
}
func Printf(text string, args ...interface{}) {
if writer != nil {
_, _ = fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
_, _ = fmt.Fprintf(writer, text+"\n", args...)
}
}
func Print(text ...interface{}) {
if writer != nil {
_, _ = fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
_, _ = fmt.Fprintln(writer, text...)
}
}
func PrintStack() {
if writer != nil {
_, _ = writer.Write(debug.Stack())
}
}
// Recover recovers a panic, runs the OnRecover handler and either re-panics or
// shows an user-friendly message about the panic depending on whether or not
// the pretty panic mode is enabled.
func Recover() {
if p := recover(); p != nil {
if OnRecover != nil {
OnRecover()
}
if RecoverPrettyPanic {
PrettyPanic(p)
} else {
panic(p)
}
}
}
const Oops = ` __________
< Oh noes! >
\
\ ^__^
\ (XX)\_______
(__)\ )\/\
U ||----W |
|| ||
A fatal error has occurred.
`
func PrettyPanic(panic interface{}) {
fmt.Print(Oops)
traceFile := fmt.Sprintf(filepath.Join(LogDirectory, "panic-%s.txt"), time.Now().Format("2006-01-02--15-04-05"))
var buf bytes.Buffer
_, _ = fmt.Fprintln(&buf, panic)
buf.Write(debug.Stack())
err := ioutil.WriteFile(traceFile, buf.Bytes(), 0640)
if err != nil {
fmt.Println("Saving the stack trace to", traceFile, "failed:")
fmt.Println("--------------------------------------------------------------------------------")
fmt.Println(err)
fmt.Println("--------------------------------------------------------------------------------")
fmt.Println("")
fmt.Println("You can file an issue at https://github.com/tulir/gomuks/issues.")
fmt.Println("Please provide the file save error (above) and the stack trace of the original error (below) when filing an issue.")
fmt.Println("")
fmt.Println("--------------------------------------------------------------------------------")
fmt.Println(panic)
debug.PrintStack()
fmt.Println("--------------------------------------------------------------------------------")
} else {
fmt.Println("The stack trace has been saved to", traceFile)
fmt.Println("")
fmt.Println("You can file an issue at https://github.com/tulir/gomuks/issues.")
fmt.Println("Please provide the contents of that file when filing an issue.")
}
os.Exit(1)
}

View file

@ -1,2 +0,0 @@
// Package debug contains utilities to log debug messages and display panics nicely.
package debug

3
desktop/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.task
bin
build/appimage

54
desktop/Taskfile.yml Normal file
View file

@ -0,0 +1,54 @@
version: '3'
includes:
common: ./build/Taskfile.common.yml
windows: ./build/Taskfile.windows.yml
darwin: ./build/Taskfile.darwin.yml
linux: ./build/Taskfile.linux.yml
vars:
APP_NAME: "gomuks-desktop"
BIN_DIR: "bin"
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks:
build:
summary: Builds the application
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
cmds:
- task: "{{OS}}:package"
run:
summary: Runs the application
cmds:
- task: "{{OS}}:run"
dev:
summary: Runs the application in development mode
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
darwin:build:universal:
summary: Builds darwin universal binary (arm64 + amd64)
cmds:
- task: darwin:build
vars:
ARCH: amd64
- mv {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-amd64
- task: darwin:build
vars:
ARCH: arm64
- mv {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-arm64
- lipo -create -output {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-amd64 {{.BIN_DIR}}/{{.APP_NAME}}-arm64
- rm {{.BIN_DIR}}/{{.APP_NAME}}-amd64 {{.BIN_DIR}}/{{.APP_NAME}}-arm64
darwin:package:universal:
summary: Packages darwin universal binary (arm64 + amd64)
deps:
- darwin:build:universal
cmds:
- task: darwin:create:app:bundle

View file

@ -0,0 +1,32 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>gomuks desktop</string>
<key>CFBundleExecutable</key>
<string>gomuks-desktop</string>
<key>CFBundleIdentifier</key>
<string>fi.mau.gomuks.desktop</string>
<key>CFBundleVersion</key>
<string>0.4.0</string>
<key>CFBundleGetInfoString</key>
<string></string>
<key>CFBundleShortVersionString</key>
<string>0.4.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2024, gomuks authors</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

27
desktop/build/Info.plist Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>gomuks desktop</string>
<key>CFBundleExecutable</key>
<string>gomuks-desktop</string>
<key>CFBundleIdentifier</key>
<string>fi.mau.gomuks.desktop</string>
<key>CFBundleVersion</key>
<string>0.4.0</string>
<key>CFBundleGetInfoString</key>
<string></string>
<key>CFBundleShortVersionString</key>
<string>0.4.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2024, gomuks authors</string>
</dict>
</plist>

View file

@ -0,0 +1,75 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
generates:
- go.sum
sources:
- go.mod
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
dir: ../web
sources:
- package.json
- package-lock.json
generates:
- node_modules/*
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install
build:frontend:
summary: Build the frontend project
dir: ../web
sources:
- "**/*"
generates:
- dist/*
deps:
- task: install:frontend:deps
#- task: generate:bindings
cmds:
- npm run build -q
generate:bindings:
summary: Generates bindings for the frontend
sources:
- "**/*.go"
- go.mod
- go.sum
generates: []
#- "frontend/bindings/**/*"
cmds: []
#- wails3 generate bindings -f '{{.BUILD_FLAGS}}'{{if .UseTypescript}} -ts{{end}}
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` files from an image
dir: build
sources:
- "appicon.png"
generates:
- "icons.icns"
- "icons.ico"
cmds:
- wails3 generate icons -input appicon.png
dev:frontend:
summary: Runs the frontend in development mode
dir: ../web
deps:
- task: install:frontend:deps
cmds:
- npm run dev -- --port {{.VITE_PORT}} --strictPort
update:build-assets:
summary: Updates the build assets
dir: build
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .

View file

@ -0,0 +1,47 @@
version: '3'
includes:
common: Taskfile.common.yml
tasks:
build:
summary: Creates a production build of the application
deps: []
#- task: common:go:mod:tidy
#- task: common:build:frontend
#- task: common:generate:icons
cmds:
- MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- GO_LDFLAGS="-s -w -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=11.0"
CGO_LDFLAGS: "-mmacosx-version-min=11.0"
MACOSX_DEPLOYMENT_TARGET: "11.0"
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application into a `.app` bundle
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: create:app:bundle
create:app:bundle:
summary: Creates an `.app` bundle
cmds:
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources}
- cp build/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS
- cp build/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View file

@ -0,0 +1,117 @@
version: '3'
includes:
common: Taskfile.common.yml
tasks:
build:
summary: Builds the application for Linux
deps: []
#- task: common:go:mod:tidy
#- task: common:build:frontend
#- task: common:generate:icons
cmds:
- MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- GO_LDFLAGS="-s -w -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
- go build {{.BUILD_FLAGS}} -ldflags "$GO_LDFLAGS" -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application for Linux
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
#- task: create:appimage
- task: create:deb
#- task: create:rpm
#- task: create:aur
create:appimage:
summary: Creates an AppImage
dir: build/appimage
deps:
- task: build
vars:
PRODUCTION: "true"
- task: generate:dotdesktop
cmds:
- cp {{.APP_BINARY}} {{.APP_NAME}}
- cp ../appicon.png appicon.png
- wails3 generate appimage -binary {{.APP_NAME}} -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/appimage
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../bin/{{.APP_NAME}}'
ICON: '../appicon.png'
DESKTOP_FILE: '{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../bin'
create:deb:
summary: Creates a deb package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:deb
create:rpm:
summary: Creates a rpm package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:rpm
create:aur:
summary: Creates a arch linux packager package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:aur
generate:deb:
summary: Creates a deb package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format deb -config ./build/nfpm/nfpm.yaml
generate:rpm:
summary: Creates a rpm package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format rpm -config ./build/nfpm/nfpm.yaml
generate:aur:
summary: Creates a arch linux packager package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format arch -config ./build/nfpm/nfpm.yaml
generate:dotdesktop:
summary: Generates a `.desktop` file
dir: build
cmds:
- mkdir -p {{.ROOT_DIR}}/build/nfpm/bin
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile {{.ROOT_DIR}}/build/{{.APP_NAME}}.desktop -categories "{{.CATEGORIES}}"
- cp {{.ROOT_DIR}}/build/{{.APP_NAME}}.desktop {{.ROOT_DIR}}/build/nfpm/bin/{{.APP_NAME}}.desktop
vars:
APP_NAME: '{{.APP_NAME}}'
EXEC: '{{.APP_NAME}}'
ICON: 'appicon'
CATEGORIES: 'Network;InstantMessaging;Chat;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/{{.APP_NAME}}.desktop'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View file

@ -0,0 +1,62 @@
version: '3'
includes:
common: Taskfile.common.yml
tasks:
build:
summary: Builds the application for Windows
deps:
#- task: common:go:mod:tidy
#- task: common:build:frontend
#- task: common:generate:icons
- task: generate:syso
cmds:
- MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- GO_LDFLAGS="-s -w -H windowsgui -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
- go build {{.BUILD_FLAGS}} -ldflags "$GO_LDFLAGS" -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: windows
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application into a `.exe` bundle
cmds:
- task: build
vars:
PRODUCTION: "true"
#cmds:
# - task: create:nsis:installer
generate:syso:
summary: Generates Windows `.syso` file
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon icon.ico -manifest wails.exe.manifest -info info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: Creates an NSIS installer
dir: build/nsis
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
run:
cmds:
- '{{.BIN_DIR}}\\{{.APP_NAME}}.exe'

1
desktop/build/appicon.png Symbolic link
View file

@ -0,0 +1 @@
../../web/public/gomuks.png

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Copyright (c) 2018-Present Lea Anthony
# SPDX-License-Identifier: MIT
# Fail script on any error
set -euxo pipefail
# Define variables
APP_DIR="${APP_NAME}.AppDir"
# Create AppDir structure
mkdir -p "${APP_DIR}/usr/bin"
cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
cp "${ICON_PATH}" "${APP_DIR}/"
cp "${DESKTOP_FILE}" "${APP_DIR}/"
# Download linuxdeploy and make it executable
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage
# Run linuxdeploy to bundle the application
./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
# Rename the generated AppImage
mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"

60
desktop/build/config.yml Normal file
View file

@ -0,0 +1,60 @@
# This file contains the configuration for this project.
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
# Note that this will overwrite any changes you have made to the assets.
version: '3'
info:
companyName: ""
productName: "gomuks desktop"
productIdentifier: "fi.mau.gomuks.desktop"
description: "A Matrix client written in Go and React"
copyright: "© 2024, gomuks authors"
comments: ""
version: "0.4.0"
# Dev mode configuration
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
watched_extension:
- "*.go"
git_ignore: true
executes:
- cmd: wails3 task common:install:frontend:deps
type: once
- cmd: wails3 task common:dev:frontend
type: background
- cmd: go mod tidy
type: blocking
- cmd: wails3 task build
type: blocking
- cmd: wails3 task run
type: primary
# File Associations
# More information at: https://v3alpha.wails.io/noit/done/yet
fileAssociations:
# - ext: wails
# name: Wails
# description: Wails Application File
# iconName: wailsFileIcon
# role: Editor
# - ext: jpg
# name: JPEG
# description: Image File
# iconName: jpegFileIcon
# role: Editor
# Other data
other: []

BIN
desktop/build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
desktop/build/icons.icns Normal file

Binary file not shown.

15
desktop/build/info.json Normal file
View file

@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "0.4.0"
},
"info": {
"0000": {
"ProductVersion": "0.4.0",
"CompanyName": "",
"FileDescription": "A Matrix client written in Go and React",
"LegalCopyright": "© 2024, gomuks authors",
"ProductName": "gomuks desktop",
"Comments": ""
}
}
}

View file

@ -0,0 +1,50 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "gomuks-desktop"
arch: ${GOARCH}
platform: "linux"
version: "0.4.0"
section: "default"
priority: "extra"
maintainer: Tulir Asokan <tulir@maunium.net>
description: "A Matrix client written in Go and React"
vendor: ""
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/gomuks-desktop"
dst: "/usr/local/bin/gomuks-desktop"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/gomuks-desktop.png"
- src: "./build/gomuks-desktop.desktop"
dst: "/usr/share/applications/gomuks-desktop.desktop"
depends:
- gtk3
- libwebkit2gtk
# replaces:
# - foobar
# provides:
# - bar
# depends:
# - gtk3
# - libwebkit2gtk
recommends:
- ffmpeg
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"
# scripts:
# preinstall: ./build/nfpm/scripts/preinstall.sh
# postinstall: ./build/nfpm/scripts/postinstall.sh
# preremove: ./build/nfpm/scripts/preremove.sh
# postremove: ./build/nfpm/scripts/postremove.sh

View file

@ -0,0 +1 @@
#!/bin/bash

View file

@ -0,0 +1 @@
#!/bin/bash

View file

@ -0,0 +1 @@
#!/bin/bash

View file

@ -0,0 +1 @@
#!/bin/bash

View file

@ -0,0 +1,112 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "my-project" # Default "gomuks-desktop"
## !define INFO_COMPANYNAME "My Company" # Default ""
## !define INFO_PRODUCTNAME "My Product Name" # Default "gomuks desktop"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© gomuks authors"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.deleteUninstaller
SectionEnd

View file

@ -0,0 +1,212 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "gomuks-desktop"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME ""
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "gomuks desktop"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.4.0"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2024, gomuks authors"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="fi.mau.gomuks.desktop" version="0.4.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

86
desktop/go.mod Normal file
View file

@ -0,0 +1,86 @@
module go.mau.fi/gomuks/desktop
go 1.23.0
toolchain go1.23.5
require github.com/wailsapp/wails/v3 v3.0.0-alpha.9
require (
go.mau.fi/gomuks v0.4.0
go.mau.fi/util v0.8.6
)
require (
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/adrg/xdg v0.5.0 // indirect
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/buckket/go-blurhash v1.1.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/cloudflare/circl v1.3.8 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.5 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/ebitengine/purego v0.4.0-alpha.4 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.0 // indirect
github.com/go-git/go-git/v5 v5.12.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.0 // indirect
github.com/lmittmann/tint v1.0.4 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/samber/lo v1.38.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wailsapp/go-webview2 v1.0.19 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
go.mau.fi/webp v0.2.0 // indirect
go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4 // indirect
mvdan.cc/xurls/v2 v2.6.0 // indirect
)
replace go.mau.fi/gomuks => ../

266
desktop/go.sum Normal file
View file

@ -0,0 +1,266 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc=
github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a h1:S+AGcmAESQ0pXCUNnRH7V+bOUIgkSX5qVt2cNKCrm0Q=
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v3 v3.0.0-alpha.9 h1:b8CfRrhPno8Fra0xFp4Ifyj+ogmXBc35rsQWvcrHtsI=
github.com/wailsapp/wails/v3 v3.0.0-alpha.9/go.mod h1:dSv6s722nSWaUyUiapAM1DHc5HKggNGY1a79shO85/g=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4 h1:H0nImAaVSHbTfhW0rGZbwRTkHJFV6hMyXBDaS2T6MvA=
maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=

168
desktop/main.go Normal file
View file

@ -0,0 +1,168 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"net/http"
"os"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
"go.mau.fi/util/exhttp"
"go.mau.fi/gomuks/pkg/gomuks"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/version"
"go.mau.fi/gomuks/web"
)
type PointableHandler struct {
handler http.Handler
}
var _ http.Handler = (*PointableHandler)(nil)
func (p *PointableHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.handler.ServeHTTP(w, r)
}
type CommandHandler struct {
Gomuks *gomuks.Gomuks
Ctx context.Context
App *application.App
}
func (c *CommandHandler) HandleCommand(cmd *hicli.JSONCommand) *hicli.JSONCommand {
return c.Gomuks.Client.SubmitJSONCommand(c.Ctx, cmd)
}
func (c *CommandHandler) Init() {
c.Gomuks.Log.Info().Msg("Sending initial state to client")
c.App.EmitEvent("hicli_event", &hicli.JSONCommandCustom[*hicli.ClientState]{
Command: "client_state",
Data: c.Gomuks.Client.State(),
})
c.App.EmitEvent("hicli_event", &hicli.JSONCommandCustom[*hicli.SyncStatus]{
Command: "sync_status",
Data: c.Gomuks.Client.SyncStatus.Load(),
})
if c.Gomuks.Client.IsLoggedIn() {
go func() {
log := c.Gomuks.Log
ctx := log.WithContext(context.TODO())
var roomCount int
for payload := range c.Gomuks.Client.GetInitialSync(ctx, 100) {
roomCount += len(payload.Rooms)
marshaledPayload, err := json.Marshal(&payload)
if err != nil {
log.Err(err).Msg("Failed to marshal initial rooms to send to client")
return
}
c.App.EmitEvent("hicli_event", &hicli.JSONCommand{
Command: "sync_complete",
RequestID: 0,
Data: marshaledPayload,
})
}
if ctx.Err() != nil {
return
}
c.App.EmitEvent("hicli_event", &hicli.JSONCommand{
Command: "init_complete",
RequestID: 0,
})
log.Info().Int("room_count", roomCount).Msg("Sent initial rooms to client")
}()
}
}
func main() {
gmx := gomuks.NewGomuks()
gmx.Version = version.Version
gmx.Commit = version.Commit
gmx.LinkifiedVersion = version.LinkifiedVersion
gmx.BuildTime = version.ParsedBuildTime
gmx.DisableAuth = true
exhttp.AutoAllowCORS = false
hicli.InitialDeviceDisplayName = "gomuks desktop"
gmx.InitDirectories()
err := gmx.LoadConfig()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to load config:", err)
os.Exit(9)
}
gmx.SetupLog()
gmx.Log.Info().
Str("version", gmx.Version).
Str("go_version", runtime.Version()).
Time("built_at", gmx.BuildTime).
Msg("Initializing gomuks desktop")
gmx.StartClient()
gmx.Log.Info().Msg("Initialization complete, starting desktop app")
cmdCtx, cancelCmdCtx := context.WithCancel(context.Background())
ch := &CommandHandler{Gomuks: gmx, Ctx: cmdCtx}
app := application.New(application.Options{
Name: "gomuks-desktop",
Description: "A Matrix client written in Go and React",
Services: []application.Service{
application.NewService(
&PointableHandler{gmx.CreateAPIRouter()},
application.ServiceOptions{Route: "/_gomuks"},
),
application.NewService(ch),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(web.Frontend),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: true,
},
OnShutdown: func() {
cancelCmdCtx()
gmx.Log.Info().Msg("Shutting down...")
gmx.DirectStop()
gmx.Log.Info().Msg("Shutdown complete")
},
})
ch.App = app
app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
Title: "gomuks desktop",
Mac: application.MacWindow{
InvisibleTitleBarHeight: 50,
Backdrop: application.MacBackdropTranslucent,
TitleBar: application.MacTitleBarHiddenInset,
},
BackgroundColour: application.NewRGB(27, 38, 54),
URL: "/",
})
gmx.EventBuffer.Subscribe(0, nil, func(command *hicli.JSONCommand) {
app.EmitEvent("hicli_event", command)
})
err = app.Run()
if err != nil {
panic(err)
}
}

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1732521221,
"narHash": "sha256-2ThgXBUXAE1oFsVATK1ZX9IjPcS4nKFOAjhPNKuiMn0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4633a7c72337ea8fd23a4f2ba3972865e3ec685d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

40
flake.nix Normal file
View file

@ -0,0 +1,40 @@
{
description = "Gomuks development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
(flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config.permittedInsecurePackages = [ "olm-3.2.16" ];
};
in {
devShells = {
default = pkgs.mkShell {
packages = with pkgs; [
glib-networking
go-task
go-tools
gotools
gst_all_1.gstreamer
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good
gst_all_1.gst-plugins-bad
gst_all_1.gst-plugins-ugly
gst_all_1.gst-libav
gst_all_1.gst-vaapi
libsoup
olm
pkg-config
pre-commit
webkitgtk_4_1
];
};
};
}));
}

64
go.mod
View file

@ -1,29 +1,47 @@
module maunium.net/go/gomuks module go.mau.fi/gomuks
go 1.14 go 1.23.0
toolchain go1.24.1
require ( require (
github.com/alecthomas/chroma v0.8.2 github.com/alecthomas/chroma/v2 v2.15.0
github.com/buckket/go-blurhash v1.1.0
github.com/chzyer/readline v1.5.1
github.com/coder/websocket v1.8.13
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/gabriel-vasile/mimetype v1.2.0 github.com/gabriel-vasile/mimetype v1.4.8
github.com/kyokomi/emoji/v2 v2.2.8
github.com/lithammer/fuzzysearch v1.1.1
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.9 github.com/mattn/go-sqlite3 v1.14.24
github.com/mattn/go-sqlite3 v1.14.6 github.com/rivo/uniseg v0.4.7
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/rs/zerolog v1.34.0
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/tidwall/gjson v1.18.0
github.com/rivo/uniseg v0.2.0 github.com/tidwall/sjson v1.2.5
github.com/russross/blackfriday/v2 v2.1.0 github.com/yuin/goldmark v1.7.8
github.com/sasha-s/go-deadlock v0.2.0 go.mau.fi/util v0.8.6
github.com/zyedidia/clipboard v1.0.3 go.mau.fi/webp v0.2.0
go.etcd.io/bbolt v1.3.5 go.mau.fi/zeroconfig v0.1.3
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb golang.org/x/crypto v0.36.0
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 golang.org/x/image v0.25.0
gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 golang.org/x/net v0.38.0
gopkg.in/vansante/go-ffprobe.v2 v2.0.2 golang.org/x/text v0.23.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.9.26 maunium.net/go/mauflag v1.0.0
maunium.net/go/mauview v0.1.2 maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4
maunium.net/go/tcell v0.2.0 mvdan.cc/xurls/v2 v2.6.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.32.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
) )

233
go.sum
View file

@ -1,154 +1,105 @@
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/alecthomas/chroma v0.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.2.0 h1:A6z5J8OhjiWFV91sQ3dMI8apYu/tvP9keDaMM3Xu6p4= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.2.0/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/kyokomi/emoji/v2 v2.2.8 h1:jcofPxjHWEkJtkIbcLHvZhxKgCPl6C7MyjTrD4KDqUE=
github.com/kyokomi/emoji/v2 v2.2.8/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8=
github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lithammer/fuzzysearch v1.1.1 h1:8F9OAV2xPuYblToVohjanztdnPjbtA0MLgMvDKQ0Z08=
github.com/lithammer/fuzzysearch v1.1.1/go.mod h1:H2bng+w5gsR7NlfIJM8ElGZI0sX6C/9uzGqicVXGU6c=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a h1:S+AGcmAESQ0pXCUNnRH7V+bOUIgkSX5qVt2cNKCrm0Q=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/sasha-s/go-deadlock v0.2.0 h1:lMqc+fUb7RrFS3gQLtoQsJ7/6TV/pAIFvBsqX73DK8Y= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sasha-s/go-deadlock v0.2.0/go.mod h1:StQn567HiB1fF2yJ44N9au7wOhrPS3iZqiDbRupzT10= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/gjson v1.6.8 h1:CTmXMClGYPAmln7652e69B7OLXfTi5ABcPPwjIWUv7w= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/match v1.0.3 h1:FQUVvBImDutD8wJLN6c5eMzWtjgONK9MwIBCOrUJKeE= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/tidwall/sjson v1.1.5 h1:wsUceI/XDyZk3J1FUvuuYlK62zJv2HO2Pzb8A5EWdUE= go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
github.com/tidwall/sjson v1.1.5/go.mod h1:VuJzsZnTowhSxWdOgsAnb886i4AjEyTkk7tNtsL7EYE= go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
github.com/zyedidia/clipboard v0.0.0-20200421031010-7c45b8673834/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
github.com/zyedidia/clipboard v1.0.3 h1:F/nCDVYMdbDWTmY8s8cJl0tnwX32q96IF09JHM14bUI= go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
github.com/zyedidia/clipboard v1.0.3/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
github.com/zyedidia/poller v1.0.1 h1:Tt9S3AxAjXwWGNiC2TUdRJkQDZSzCBNVQ4xXiQ7440s= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
github.com/zyedidia/poller v1.0.1/go.mod h1:vZXJOHGDcuK08GXhF6IAY0ZFd2WcgOR5DOTp84Uk5eE= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 h1:/ZScEX8SfEmUGRHs0gxpqteO5nfNW6axyZbBdw9A12g=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 h1:MZF6J7CV6s/h0HBkfqebrYfKCVEo5iN+wzE4QhV3Evo= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2/go.mod h1:s1Sn2yZos05Qfs7NKt867Xe18emOmtsO3eAKbDaon0o= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/vansante/go-ffprobe.v2 v2.0.2 h1:DdxSfFnlqeawPIVbIQEI6LR6OQHQNR7tNgWb2mWuC4w= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
gopkg.in/vansante/go-ffprobe.v2 v2.0.2/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4 h1:H0nImAaVSHbTfhW0rGZbwRTkHJFV6hMyXBDaS2T6MvA=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/maulogger/v2 v2.2.4 h1:oV2GDeM4fx1uRysdpDC0FcrPg+thFicSd9XzPcYMbVY=
maunium.net/go/maulogger/v2 v2.2.4/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/mautrix v0.9.26 h1:WQ8Voygilj+yVh1K7YFMmZ647zeoXy3houhWUmtCsfs=
maunium.net/go/mautrix v0.9.26/go.mod h1:7IzKfWvpQtN+W2Lzxc0rLvIxFM3ryKX6Ys3S/ZoWbg8=
maunium.net/go/mauview v0.1.2 h1:6Y3GpyckIlzCNkry6k025YhWg8oh5XJFj3RAMf4VwWo=
maunium.net/go/mauview v0.1.2/go.mod h1:3QBUiuLct9moP1LgDhCGIg0Ovxn38Bd2sGndnUOuj4o=
maunium.net/go/tcell v0.2.0 h1:1Q0kN3wCOGAIGu1r3QHADsjSUOPDylKREvCv3EzJpVg=
maunium.net/go/tcell v0.2.0/go.mod h1:9Apcb3lNNS6C6lCqKT9UFp7BTRzHXfWE+/tgufsAMho=

133
gomuks.go
View file

@ -1,133 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"os"
"os/signal"
"syscall"
"time"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/matrix"
)
// Gomuks is the wrapper for everything.
type Gomuks struct {
ui ifc.GomuksUI
matrix *matrix.Container
config *config.Config
stop chan bool
}
// NewGomuks creates a new Gomuks instance with everything initialized,
// but does not start it.
func NewGomuks(uiProvider ifc.UIProvider, configDir, dataDir, cacheDir, downloadDir string) *Gomuks {
gmx := &Gomuks{
stop: make(chan bool, 1),
}
gmx.config = config.NewConfig(configDir, dataDir, cacheDir, downloadDir)
gmx.ui = uiProvider(gmx)
gmx.matrix = matrix.NewContainer(gmx)
gmx.config.LoadAll()
gmx.ui.Init()
debug.OnRecover = gmx.ui.Finish
return gmx
}
func (gmx *Gomuks) Version() string {
return "v0.2.4"
}
// Save saves the active session and message history.
func (gmx *Gomuks) Save() {
gmx.config.SaveAll()
}
// StartAutosave calls Save() every minute until it receives a stop signal
// on the Gomuks.stop channel.
func (gmx *Gomuks) StartAutosave() {
defer debug.Recover()
ticker := time.NewTicker(time.Minute)
for {
select {
case <-ticker.C:
if gmx.config.AuthCache.InitialSyncDone {
gmx.Save()
}
case val := <-gmx.stop:
if val {
return
}
}
}
}
// Stop stops the Matrix syncer, the tview app and the autosave goroutine,
// then saves everything and calls os.Exit(0).
func (gmx *Gomuks) Stop(save bool) {
debug.Print("Disconnecting from Matrix...")
gmx.matrix.Stop()
debug.Print("Cleaning up UI...")
gmx.ui.Stop()
gmx.stop <- true
if save {
gmx.Save()
}
os.Exit(0)
}
// Start opens a goroutine for the autosave loop and starts the tview app.
//
// If the tview app returns an error, it will be passed into panic(), which
// will be recovered as specified in Recover().
func (gmx *Gomuks) Start() {
_ = gmx.matrix.InitClient()
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
gmx.Stop(true)
}()
go gmx.StartAutosave()
if err := gmx.ui.Start(); err != nil {
panic(err)
}
}
// Matrix returns the MatrixContainer instance.
func (gmx *Gomuks) Matrix() ifc.MatrixContainer {
return gmx.matrix
}
// Config returns the Gomuks config instance.
func (gmx *Gomuks) Config() *config.Config {
return gmx.config
}
// UI returns the Gomuks UI instance.
func (gmx *Gomuks) UI() ifc.GomuksUI {
return gmx.ui
}

View file

@ -1,2 +0,0 @@
// Package ifc contains interfaces to allow circular function calls without circular imports.
package ifc

View file

@ -1,92 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ifc
import (
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/matrix/rooms"
)
type Relation struct {
Type event.RelationType
Event *muksevt.Event
}
type UploadedMediaInfo struct {
*mautrix.RespMediaUpload
EncryptionInfo *attachment.EncryptedFile
MsgType event.MessageType
Name string
Info *event.FileInfo
}
type MatrixContainer interface {
Client() *mautrix.Client
Preferences() *config.UserPreferences
InitClient() error
Initialized() bool
Start()
Stop()
Login(user, password string) error
Logout()
UIAFallback(authType mautrix.AuthType, sessionID string) error
SendPreferencesToMatrix()
PrepareMarkdownMessage(roomID id.RoomID, msgtype event.MessageType, text, html string, relation *Relation) *muksevt.Event
PrepareMediaMessage(room *rooms.Room, path string, relation *Relation) (*muksevt.Event, error)
SendEvent(evt *muksevt.Event) (id.EventID, error)
Redact(roomID id.RoomID, eventID id.EventID, reason string) error
SendTyping(roomID id.RoomID, typing bool)
MarkRead(roomID id.RoomID, eventID id.EventID)
JoinRoom(roomID id.RoomID, server string) (*rooms.Room, error)
LeaveRoom(roomID id.RoomID) error
CreateRoom(req *mautrix.ReqCreateRoom) (*rooms.Room, error)
FetchMembers(room *rooms.Room) error
GetHistory(room *rooms.Room, limit int, dbPointer uint64) ([]*muksevt.Event, uint64, error)
GetEvent(room *rooms.Room, eventID id.EventID) (*muksevt.Event, error)
GetRoom(roomID id.RoomID) *rooms.Room
GetOrCreateRoom(roomID id.RoomID) *rooms.Room
UploadMedia(path string, encrypt bool) (*UploadedMediaInfo, error)
Download(uri id.ContentURI, file *attachment.EncryptedFile) ([]byte, error)
DownloadToDisk(uri id.ContentURI, file *attachment.EncryptedFile, target string) (string, error)
GetDownloadURL(uri id.ContentURI) string
GetCachePath(uri id.ContentURI) string
Crypto() Crypto
}
type Crypto interface {
Load() error
FlushStore() error
ProcessSyncResponse(resp *mautrix.RespSync, since string) bool
ProcessInRoomVerification(evt *event.Event) error
HandleMemberEvent(*event.Event)
DecryptMegolmEvent(*event.Event) (*event.Event, error)
EncryptMegolmEvent(id.RoomID, event.Type, interface{}) (*event.EncryptedEventContent, error)
ShareGroupSession(id.RoomID, []id.UserID) error
Fingerprint() string
}

View file

@ -1,89 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ifc
import (
"time"
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/matrix/rooms"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
)
type UIProvider func(gmx Gomuks) GomuksUI
type GomuksUI interface {
Render()
HandleNewPreferences()
OnLogin()
OnLogout()
MainView() MainView
Init()
Start() error
Stop()
Finish()
}
type SyncingModal interface {
SetIndeterminate()
SetMessage(string)
SetSteps(int)
Step()
Close()
}
type MainView interface {
GetRoom(roomID id.RoomID) RoomView
AddRoom(room *rooms.Room)
RemoveRoom(room *rooms.Room)
SetRooms(rooms *rooms.RoomCache)
Bump(room *rooms.Room)
UpdateTags(room *rooms.Room)
SetTyping(roomID id.RoomID, users []id.UserID)
OpenSyncingModal() SyncingModal
NotifyMessage(room *rooms.Room, message Message, should pushrules.PushActionArrayShould)
}
type RoomView interface {
MxRoom() *rooms.Room
SetCompletions(completions []string)
SetTyping(users []id.UserID)
UpdateUserList()
AddEvent(evt *muksevt.Event) Message
AddRedaction(evt *muksevt.Event)
AddEdit(evt *muksevt.Event)
AddReaction(evt *muksevt.Event, key string)
GetEvent(eventID id.EventID) Message
AddServiceMessage(message string)
}
type Message interface {
ID() id.EventID
Time() time.Time
NotificationSenderName() string
NotificationContent() string
SetIsHighlight(highlight bool)
SetID(id id.EventID)
}

View file

@ -1,295 +0,0 @@
// ___ _____ ____
// / _ \/ _/ |/_/ /____ ______ _
// / ___// /_> </ __/ -_) __/ ' \
// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
//
// Copyright 2017 Eliuk Blau
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package ansimage
import (
"errors"
"image"
"image/color"
"image/draw"
_ "image/gif" // initialize decoder
_ "image/jpeg" // initialize decoder
_ "image/png" // initialize decoder
"io"
"os"
"github.com/disintegration/imaging"
_ "golang.org/x/image/bmp" // initialize decoder
_ "golang.org/x/image/tiff" // initialize decoder
_ "golang.org/x/image/webp" // initialize decoder
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/ui/messages/tstring"
"maunium.net/go/tcell"
)
var (
// ErrHeightNonMoT happens when ANSImage height is not a Multiple of Two value.
ErrHeightNonMoT = errors.New("ANSImage: height must be a Multiple of Two value")
// ErrInvalidBoundsMoT happens when ANSImage height or width are invalid values (Multiple of Two).
ErrInvalidBoundsMoT = errors.New("ANSImage: height or width must be >=2")
// ErrOutOfBounds happens when ANSI-pixel coordinates are out of ANSImage bounds.
ErrOutOfBounds = errors.New("ANSImage: out of bounds")
)
// ANSIpixel represents a pixel of an ANSImage.
type ANSIpixel struct {
Brightness uint8
R, G, B uint8
upper bool
source *ANSImage
}
// ANSImage represents an image encoded in ANSI escape codes.
type ANSImage struct {
h, w int
maxprocs int
bgR uint8
bgG uint8
bgB uint8
pixmap [][]*ANSIpixel
}
func (ai *ANSImage) Pixmap() [][]*ANSIpixel {
return ai.pixmap
}
// Height gets total rows of ANSImage.
func (ai *ANSImage) Height() int {
return ai.h
}
// Width gets total columns of ANSImage.
func (ai *ANSImage) Width() int {
return ai.w
}
// SetMaxProcs sets the maximum number of parallel goroutines to render the ANSImage
// (user should manually sets `runtime.GOMAXPROCS(max)` before to this change takes effect).
func (ai *ANSImage) SetMaxProcs(max int) {
ai.maxprocs = max
}
// GetMaxProcs gets the maximum number of parallels goroutines to render the ANSImage.
func (ai *ANSImage) GetMaxProcs() int {
return ai.maxprocs
}
// SetAt sets ANSI-pixel color (RBG) and brightness in coordinates (y,x).
func (ai *ANSImage) SetAt(y, x int, r, g, b, brightness uint8) error {
if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
ai.pixmap[y][x].R = r
ai.pixmap[y][x].G = g
ai.pixmap[y][x].B = b
ai.pixmap[y][x].Brightness = brightness
ai.pixmap[y][x].upper = y%2 == 0
return nil
}
return ErrOutOfBounds
}
// GetAt gets ANSI-pixel in coordinates (y,x).
func (ai *ANSImage) GetAt(y, x int) (*ANSIpixel, error) {
if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
return &ANSIpixel{
R: ai.pixmap[y][x].R,
G: ai.pixmap[y][x].G,
B: ai.pixmap[y][x].B,
Brightness: ai.pixmap[y][x].Brightness,
upper: ai.pixmap[y][x].upper,
source: ai.pixmap[y][x].source,
},
nil
}
return nil, ErrOutOfBounds
}
// Render returns the ANSI-compatible string form of ANSImage.
// (Nice info for ANSI True Colour - https://gist.github.com/XVilka/8346728)
func (ai *ANSImage) Render() []tstring.TString {
type renderData struct {
row int
render tstring.TString
}
rows := make([]tstring.TString, ai.h/2)
for y := 0; y < ai.h; y += ai.maxprocs {
ch := make(chan renderData, ai.maxprocs)
for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n {
go func(row, y int) {
defer func() {
err := recover()
if err != nil {
debug.Print("Panic rendering ANSImage:", err)
ch <- renderData{row: row, render: tstring.NewColorTString("ERROR", tcell.ColorRed)}
}
}()
str := make(tstring.TString, ai.w)
for x := 0; x < ai.w; x++ {
topPixel := ai.pixmap[y][x]
topColor := tcell.NewRGBColor(int32(topPixel.R), int32(topPixel.G), int32(topPixel.B))
bottomPixel := ai.pixmap[y+1][x]
bottomColor := tcell.NewRGBColor(int32(bottomPixel.R), int32(bottomPixel.G), int32(bottomPixel.B))
str[x] = tstring.Cell{
Char: '▄',
Style: tcell.StyleDefault.Background(topColor).Foreground(bottomColor),
}
}
ch <- renderData{row: row, render: str}
}(row, 2*row)
}
for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n {
data := <-ch
rows[data.row] = data.render
}
}
return rows
}
// New creates a new empty ANSImage ready to draw on it.
func New(h, w int, bg color.Color) (*ANSImage, error) {
if h%2 != 0 {
return nil, ErrHeightNonMoT
}
if h < 2 || w < 2 {
return nil, ErrInvalidBoundsMoT
}
r, g, b, _ := bg.RGBA()
ansimage := &ANSImage{
h: h, w: w,
maxprocs: 1,
bgR: uint8(r),
bgG: uint8(g),
bgB: uint8(b),
pixmap: nil,
}
ansimage.pixmap = func() [][]*ANSIpixel {
v := make([][]*ANSIpixel, h)
for y := 0; y < h; y++ {
v[y] = make([]*ANSIpixel, w)
for x := 0; x < w; x++ {
v[y][x] = &ANSIpixel{
R: 0,
G: 0,
B: 0,
Brightness: 0,
source: ansimage,
upper: y%2 == 0,
}
}
}
return v
}()
return ansimage, nil
}
// NewFromReader creates a new ANSImage from an io.Reader.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func NewFromReader(reader io.Reader, bg color.Color) (*ANSImage, error) {
img, _, err := image.Decode(reader)
if err != nil {
return nil, err
}
return createANSImage(img, bg)
}
// NewScaledFromReader creates a new scaled ANSImage from an io.Reader.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func NewScaledFromReader(reader io.Reader, y, x int, bg color.Color) (*ANSImage, error) {
img, _, err := image.Decode(reader)
if err != nil {
return nil, err
}
img = imaging.Resize(img, x, y, imaging.Lanczos)
return createANSImage(img, bg)
}
// NewFromFile creates a new ANSImage from a file.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func NewFromFile(name string, bg color.Color) (*ANSImage, error) {
reader, err := os.Open(name)
if err != nil {
return nil, err
}
defer reader.Close()
return NewFromReader(reader, bg)
}
// NewScaledFromFile creates a new scaled ANSImage from a file.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func NewScaledFromFile(name string, y, x int, bg color.Color) (*ANSImage, error) {
reader, err := os.Open(name)
if err != nil {
return nil, err
}
defer reader.Close()
return NewScaledFromReader(reader, y, x, bg)
}
// createANSImage loads data from an image and returns an ANSImage.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func createANSImage(img image.Image, bg color.Color) (*ANSImage, error) {
var rgbaOut *image.RGBA
bounds := img.Bounds()
// do compositing only if background color has no transparency (thank you @disq for the idea!)
// (info - http://stackoverflow.com/questions/36595687/transparent-pixel-color-go-lang-image)
if _, _, _, a := bg.RGBA(); a >= 0xffff {
rgbaOut = image.NewRGBA(bounds)
draw.Draw(rgbaOut, bounds, image.NewUniform(bg), image.ZP, draw.Src)
draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Over)
} else {
if v, ok := img.(*image.RGBA); ok {
rgbaOut = v
} else {
rgbaOut = image.NewRGBA(bounds)
draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Src)
}
}
yMin, xMin := bounds.Min.Y, bounds.Min.X
yMax, xMax := bounds.Max.Y, bounds.Max.X
// always sets an even number of ANSIPixel rows...
yMax = yMax - yMax%2 // one for upper pixel and another for lower pixel --> without dithering
ansimage, err := New(yMax, xMax, bg)
if err != nil {
return nil, err
}
for y := yMin; y < yMax; y++ {
for x := xMin; x < xMax; x++ {
v := rgbaOut.RGBAAt(x, y)
if err := ansimage.SetAt(y, x, v.R, v.G, v.B, 0); err != nil {
return nil, err
}
}
}
return ansimage, nil
}

View file

@ -1,11 +0,0 @@
// Package ansimage is a simplified version of the ansimage package
// in https://github.com/eliukblau/pixterm focused in rendering images
// to a tcell-based TUI app.
//
// ___ _____ ____
// / _ \/ _/ |/_/ /____ ______ _
// / ___// /_> </ __/ -_) __/ ' \
// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
//
// This package is licensed under the Mozilla Public License v2.0.
package ansimage

View file

@ -1,2 +0,0 @@
// Package notification contains a simple cross-platform desktop notification sending function.
package notification

View file

@ -1,65 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"fmt"
"os/exec"
)
var terminalNotifierAvailable = false
func init() {
if err := exec.Command("which", "terminal-notifier").Run(); err != nil {
terminalNotifierAvailable = false
}
terminalNotifierAvailable = true
}
const sendScript = `on run {notifText, notifTitle}
display notification notifText with title "gomuks" subtitle notifTitle
end run`
func Send(title, text string, critical, sound bool) error {
if terminalNotifierAvailable {
args := []string{"-title", "gomuks", "-subtitle", title, "-message", text}
if critical {
args = append(args, "-timeout", "15")
} else {
args = append(args, "-timeout", "4")
}
if sound {
args = append(args, "-sound", "default")
}
//if len(iconPath) > 0 {
// args = append(args, "-appIcon", iconPath)
//}
return exec.Command("terminal-notifier", args...).Run()
}
cmd := exec.Command("osascript", "-", text, title)
if stdin, err := cmd.StdinPipe(); err != nil {
return fmt.Errorf("failed to get stdin pipe for osascript: %w", err)
} else if _, err = stdin.Write([]byte(sendScript)); err != nil {
return fmt.Errorf("failed to write notification script to osascript: %w", err)
} else if err = cmd.Run(); err != nil {
return fmt.Errorf("failed to run notification script: %w", err)
} else if !cmd.ProcessState.Success() {
return fmt.Errorf("notification script exited unsuccessfully")
} else {
return nil
}
}

View file

@ -1,84 +0,0 @@
// +build !windows,!darwin
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"os"
"os/exec"
)
var notifySendPath string
var audioCommand string
var tryAudioCommands = []string{"ogg123", "paplay"}
var soundNormal = "/usr/share/sounds/freedesktop/stereo/message-new-instant.oga"
var soundCritical = "/usr/share/sounds/freedesktop/stereo/complete.oga"
func getSoundPath(env, defaultPath string) string {
if path, ok := os.LookupEnv(env); ok {
// Sound file overriden by environment
return path
} else if _, err := os.Stat(defaultPath); os.IsNotExist(err) {
// Sound file doesn't exist, disable it
return ""
} else {
// Default sound file exists and wasn't overridden by environment
return defaultPath
}
}
func init() {
var err error
if notifySendPath, err = exec.LookPath("notify-send"); err != nil {
return
}
for _, cmd := range tryAudioCommands {
if audioCommand, err = exec.LookPath(cmd); err == nil {
break
}
}
soundNormal = getSoundPath("GOMUKS_SOUND_NORMAL", soundNormal)
soundCritical = getSoundPath("GOMUKS_SOUND_CRITICAL", soundCritical)
}
func Send(title, text string, critical, sound bool) error {
if len(notifySendPath) == 0 {
return nil
}
args := []string{"-a", "gomuks"}
if !critical {
args = append(args, "-u", "low")
}
//if iconPath {
// args = append(args, "-i", iconPath)
//}
args = append(args, title, text)
if sound && len(audioCommand) > 0 && len(soundNormal) > 0 {
audioFile := soundNormal
if critical && len(soundCritical) > 0 {
audioFile = soundCritical
}
go func() {
_ = exec.Command(audioCommand, audioFile).Run()
}()
}
return exec.Command(notifySendPath, args...).Run()
}

View file

@ -1,4 +0,0 @@
// Package open contains a simple cross-platform way to open files in the program the OS wants to use.
//
// Based on https://github.com/skratchdot/open-golang
package open

View file

@ -1,4 +0,0 @@
package open
const Command = "open"
var Args []string

View file

@ -1,6 +0,0 @@
// +build !windows,!darwin
package open
const Command = "xdg-open"
var Args []string

View file

@ -1,2 +0,0 @@
// Package util contains miscellaneous utilities
package util

View file

@ -1,38 +0,0 @@
// Licensed under the GNU Free Documentation License 1.2
// https://www.gnu.org/licenses/old-licenses/fdl-1.2.en.html
//
// Source: https://rosettacode.org/wiki/Longest_common_prefix#Go
package util
func LongestCommonPrefix(list []string) string {
// Special cases first
switch len(list) {
case 0:
return ""
case 1:
return list[0]
}
// LCP of min and max (lexigraphically)
// is the LCP of the whole set.
min, max := list[0], list[0]
for _, s := range list[1:] {
switch {
case s < min:
min = s
case s > max:
max = s
}
}
for i := 0; i < len(min) && i < len(max); i++ {
if min[i] != max[i] {
return min[:i]
}
}
// In the case where lengths are not equal but all bytes
// are equal, min is the answer ("foo" < "foobar").
return min
}

173
main.go
View file

@ -1,173 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/ui"
)
var MainUIProvider ifc.UIProvider = ui.NewGomuksUI
func main() {
debugDir := os.Getenv("DEBUG_DIR")
if len(debugDir) > 0 {
debug.LogDirectory = debugDir
}
debugLevel := strings.ToLower(os.Getenv("DEBUG"))
if debugLevel != "0" && debugLevel != "f" && debugLevel != "false" {
debug.WriteLogs = true
debug.RecoverPrettyPanic = true
}
if debugLevel == "1" || debugLevel == "t" || debugLevel == "true" {
debug.RecoverPrettyPanic = false
debug.DeadlockDetection = true
}
debug.Initialize()
defer debug.Recover()
var configDir, dataDir, cacheDir, downloadDir string
var err error
configDir, err = UserConfigDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get config directory:", err)
os.Exit(3)
}
dataDir, err = UserDataDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get data directory:", err)
os.Exit(3)
}
cacheDir, err = UserCacheDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get cache directory:", err)
os.Exit(3)
}
downloadDir, err = UserDownloadDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get download directory:", err)
os.Exit(3)
}
debug.Print("Config directory:", configDir)
debug.Print("Data directory:", dataDir)
debug.Print("Cache directory:", cacheDir)
debug.Print("Download directory:", downloadDir)
gmx := NewGomuks(MainUIProvider, configDir, dataDir, cacheDir, downloadDir)
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
fmt.Printf("gomuks version %s\n", gmx.Version())
os.Exit(0)
}
gmx.Start()
// We use os.Exit() everywhere, so exiting by returning from Start() shouldn't happen.
time.Sleep(5 * time.Second)
fmt.Println("Unexpected exit by return from gmx.Start().")
os.Exit(2)
}
func getRootDir(subdir string) string {
rootDir := os.Getenv("GOMUKS_ROOT")
if rootDir == "" {
return ""
}
return filepath.Join(rootDir, subdir)
}
func UserCacheDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_CACHE_HOME")
if dir == "" {
dir = getRootDir("cache")
}
if dir == "" {
dir, err = os.UserCacheDir()
dir = filepath.Join(dir, "gomuks")
}
return
}
func UserDataDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_DATA_HOME")
if dir != "" {
return
}
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return UserConfigDir()
}
dir = os.Getenv("XDG_DATA_HOME")
if dir == "" {
dir = getRootDir("data")
}
if dir == "" {
dir = os.Getenv("HOME")
if dir == "" {
return "", errors.New("neither $XDG_DATA_HOME nor $HOME are defined")
}
dir = filepath.Join(dir, ".local", "share")
}
dir = filepath.Join(dir, "gomuks")
return
}
func getXDGUserDir(name string) (dir string, err error) {
cmd := exec.Command("xdg-user-dir", name)
var out strings.Builder
cmd.Stdout = &out
err = cmd.Run()
dir = strings.TrimSpace(out.String())
return
}
func UserDownloadDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_DOWNLOAD_HOME")
if dir != "" {
return
}
dir, _ = getXDGUserDir("DOWNLOAD")
if dir != "" {
return
}
dir, err = os.UserHomeDir()
dir = filepath.Join(dir, "Downloads")
return
}
func UserConfigDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_CONFIG_HOME")
if dir == "" {
dir = getRootDir("config")
}
if dir == "" {
dir, err = os.UserConfigDir()
dir = filepath.Join(dir, "gomuks")
}
return
}

View file

@ -1,100 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// +build cgo
package matrix
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/gomuks/debug"
)
type cryptoLogger struct {
prefix string
}
func (c cryptoLogger) Error(message string, args ...interface{}) {
debug.Printf(fmt.Sprintf("[%s/Error] %s", c.prefix, message), args...)
}
func (c cryptoLogger) Warn(message string, args ...interface{}) {
debug.Printf(fmt.Sprintf("[%s/Warn] %s", c.prefix, message), args...)
}
func (c cryptoLogger) Debug(message string, args ...interface{}) {
debug.Printf(fmt.Sprintf("[%s/Debug] %s", c.prefix, message), args...)
}
func (c cryptoLogger) Trace(message string, args ...interface{}) {
debug.Printf(fmt.Sprintf("[%s/Trace] %s", c.prefix, message), args...)
}
func isBadEncryptError(err error) bool {
return err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession
}
func (c *Container) initCrypto() error {
var cryptoStore crypto.Store
var err error
legacyStorePath := filepath.Join(c.config.DataDir, "crypto.gob")
if _, err = os.Stat(legacyStorePath); err == nil {
debug.Printf("Using legacy crypto store as %s exists", legacyStorePath)
cryptoStore, err = crypto.NewGobStore(legacyStorePath)
if err != nil {
return fmt.Errorf("file open: %w", err)
}
} else {
debug.Printf("Using SQLite crypto store")
newStorePath := filepath.Join(c.config.DataDir, "crypto.db")
db, err := sql.Open("sqlite3", newStorePath)
if err != nil {
return fmt.Errorf("sql open: %w", err)
}
accID := fmt.Sprintf("%s/%s", c.config.UserID.String(), c.config.DeviceID)
sqlStore := crypto.NewSQLCryptoStore(db, "sqlite3", accID, c.config.DeviceID, []byte("fi.mau.gomuks"), cryptoLogger{"Crypto/DB"})
err = sqlStore.CreateTables()
if err != nil {
return fmt.Errorf("create table: %w", err)
}
cryptoStore = sqlStore
}
crypt := crypto.NewOlmMachine(c.client, cryptoLogger{"Crypto"}, cryptoStore, c.config.Rooms)
crypt.AllowUnverifiedDevices = !c.config.SendToVerifiedOnly
c.crypto = crypt
err = c.crypto.Load()
if err != nil {
return fmt.Errorf("failed to create olm machine: %w", err)
}
return nil
}
func (c *Container) cryptoOnLogin() {
sqlStore, ok := c.crypto.(*crypto.OlmMachine).CryptoStore.(*crypto.SQLCryptoStore)
if !ok {
return
}
sqlStore.DeviceID = c.config.DeviceID
sqlStore.AccountID = fmt.Sprintf("%s/%s", c.config.UserID.String(), c.config.DeviceID)
}

View file

@ -1,2 +0,0 @@
// Package matrix contains wrappers for mautrix for use by the UI of gomuks.
package matrix

View file

@ -1,316 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package matrix
import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/gob"
"errors"
sync "github.com/sasha-s/go-deadlock"
bolt "go.etcd.io/bbolt"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/matrix/rooms"
)
type HistoryManager struct {
sync.Mutex
db *bolt.DB
historyEndPtr map[*rooms.Room]uint64
}
var bucketRoomStreams = []byte("room_streams")
var bucketRoomEventIDs = []byte("room_event_ids")
var bucketStreamPointers = []byte("room_stream_pointers")
const halfUint64 = ^uint64(0) >> 1
func NewHistoryManager(dbPath string) (*HistoryManager, error) {
hm := &HistoryManager{
historyEndPtr: make(map[*rooms.Room]uint64),
}
db, err := bolt.Open(dbPath, 0600, &bolt.Options{
Timeout: 1,
NoGrowSync: false,
FreelistType: bolt.FreelistArrayType,
})
if err != nil {
return nil, err
}
err = db.Update(func(tx *bolt.Tx) error {
_, err = tx.CreateBucketIfNotExists(bucketRoomStreams)
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists(bucketRoomEventIDs)
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists(bucketStreamPointers)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
hm.db = db
return hm, nil
}
func (hm *HistoryManager) Close() error {
return hm.db.Close()
}
var (
EventNotFoundError = errors.New("event not found")
RoomNotFoundError = errors.New("room not found")
)
func (hm *HistoryManager) getStreamIndex(tx *bolt.Tx, roomID []byte, eventID []byte) (*bolt.Bucket, []byte, error) {
eventIDs := tx.Bucket(bucketRoomEventIDs).Bucket(roomID)
if eventIDs == nil {
return nil, nil, RoomNotFoundError
}
index := eventIDs.Get(eventID)
if index == nil {
return nil, nil, EventNotFoundError
}
stream := tx.Bucket(bucketRoomStreams).Bucket(roomID)
return stream, index, nil
}
func (hm *HistoryManager) getEvent(tx *bolt.Tx, stream *bolt.Bucket, index []byte) (*muksevt.Event, error) {
eventData := stream.Get(index)
if eventData == nil || len(eventData) == 0 {
return nil, EventNotFoundError
}
return unmarshalEvent(eventData)
}
func (hm *HistoryManager) Get(room *rooms.Room, eventID id.EventID) (evt *muksevt.Event, err error) {
err = hm.db.View(func(tx *bolt.Tx) error {
if stream, index, err := hm.getStreamIndex(tx, []byte(room.ID), []byte(eventID)); err != nil {
return err
} else if evt, err = hm.getEvent(tx, stream, index); err != nil {
return err
}
return nil
})
return
}
func (hm *HistoryManager) Update(room *rooms.Room, eventID id.EventID, update func(evt *muksevt.Event) error) error {
return hm.db.Update(func(tx *bolt.Tx) error {
if stream, index, err := hm.getStreamIndex(tx, []byte(room.ID), []byte(eventID)); err != nil {
return err
} else if evt, err := hm.getEvent(tx, stream, index); err != nil {
return err
} else if err = update(evt); err != nil {
return err
} else if eventData, err := marshalEvent(evt); err != nil {
return err
} else if err := stream.Put(index, eventData); err != nil {
return err
}
return nil
})
}
func (hm *HistoryManager) Append(room *rooms.Room, events []*event.Event) ([]*muksevt.Event, error) {
muksEvts, _, err := hm.store(room, events, true)
return muksEvts, err
}
func (hm *HistoryManager) Prepend(room *rooms.Room, events []*event.Event) ([]*muksevt.Event, uint64, error) {
return hm.store(room, events, false)
}
func (hm *HistoryManager) store(room *rooms.Room, events []*event.Event, append bool) (newEvents []*muksevt.Event, newPtrStart uint64, err error) {
hm.Lock()
defer hm.Unlock()
newEvents = make([]*muksevt.Event, len(events))
err = hm.db.Update(func(tx *bolt.Tx) error {
streamPointers := tx.Bucket(bucketStreamPointers)
rid := []byte(room.ID)
stream, err := tx.Bucket(bucketRoomStreams).CreateBucketIfNotExists(rid)
if err != nil {
return err
}
eventIDs, err := tx.Bucket(bucketRoomEventIDs).CreateBucketIfNotExists(rid)
if err != nil {
return err
}
if stream.Sequence() < halfUint64 {
// The sequence counter (i.e. the future) the part after 2^63, i.e. the second half of uint64
// We set it to -1 because NextSequence will increment it by one.
err = stream.SetSequence(halfUint64 - 1)
if err != nil {
return err
}
}
if append {
ptrStart, err := stream.NextSequence()
if err != nil {
return err
}
for i, evt := range events {
newEvents[i] = muksevt.Wrap(evt)
if err := put(stream, eventIDs, newEvents[i], ptrStart+uint64(i)); err != nil {
return err
}
}
err = stream.SetSequence(ptrStart + uint64(len(events)) - 1)
if err != nil {
return err
}
} else {
ptrStart, ok := hm.historyEndPtr[room]
if !ok {
ptrStartRaw := streamPointers.Get(rid)
if ptrStartRaw != nil {
ptrStart = btoi(ptrStartRaw)
} else {
ptrStart = halfUint64 - 1
}
}
eventCount := uint64(len(events))
for i, evt := range events {
newEvents[i] = muksevt.Wrap(evt)
if err := put(stream, eventIDs, newEvents[i], -ptrStart-uint64(i)); err != nil {
return err
}
}
hm.historyEndPtr[room] = ptrStart + eventCount
// TODO this is not the correct value for newPtrStart, figure out what the f*ck is going on here
newPtrStart = ptrStart + eventCount
err := streamPointers.Put(rid, itob(ptrStart+eventCount))
if err != nil {
return err
}
}
return nil
})
return
}
func (hm *HistoryManager) Load(room *rooms.Room, num int, ptrStart uint64) (events []*muksevt.Event, newPtrStart uint64, err error) {
hm.Lock()
defer hm.Unlock()
err = hm.db.View(func(tx *bolt.Tx) error {
stream := tx.Bucket(bucketRoomStreams).Bucket([]byte(room.ID))
if stream == nil {
return nil
}
if ptrStart == 0 {
ptrStart = stream.Sequence() + 1
}
c := stream.Cursor()
k, v := c.Seek(itob(ptrStart - uint64(num)))
ptrStartFound := btoi(k)
if k == nil || ptrStartFound >= ptrStart {
return nil
}
newPtrStart = ptrStartFound
for ; k != nil && btoi(k) < ptrStart; k, v = c.Next() {
evt, parseError := unmarshalEvent(v)
if parseError != nil {
return parseError
}
events = append(events, evt)
}
return nil
})
// Reverse array because we read/append the history in reverse order.
i := 0
j := len(events) - 1
for i < j {
events[i], events[j] = events[j], events[i]
i++
j--
}
return
}
func itob(v uint64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, v)
return b
}
func btoi(b []byte) uint64 {
return binary.BigEndian.Uint64(b)
}
func stripRaw(evt *muksevt.Event) {
evtCopy := *evt.Event
evtCopy.Content = event.Content{
Parsed: evt.Content.Parsed,
}
evt.Event = &evtCopy
}
func marshalEvent(evt *muksevt.Event) ([]byte, error) {
stripRaw(evt)
var buf bytes.Buffer
enc, _ := gzip.NewWriterLevel(&buf, gzip.BestSpeed)
if err := gob.NewEncoder(enc).Encode(evt); err != nil {
_ = enc.Close()
return nil, err
} else if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func unmarshalEvent(data []byte) (*muksevt.Event, error) {
evt := &muksevt.Event{}
if cmpReader, err := gzip.NewReader(bytes.NewReader(data)); err != nil {
return nil, err
} else if err := gob.NewDecoder(cmpReader).Decode(evt); err != nil {
_ = cmpReader.Close()
return nil, err
} else if err := cmpReader.Close(); err != nil {
return nil, err
}
return evt, nil
}
func put(streams, eventIDs *bolt.Bucket, evt *muksevt.Event, key uint64) error {
data, err := marshalEvent(evt)
if err != nil {
return err
}
keyBytes := itob(key)
if err = streams.Put(keyBytes, data); err != nil {
return err
}
if err = eventIDs.Put([]byte(evt.ID), keyBytes); err != nil {
return err
}
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -1,106 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package matrix
import (
"context"
"fmt"
"image"
"os"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"gopkg.in/vansante/go-ffprobe.v2"
"maunium.net/go/mautrix/event"
"maunium.net/go/gomuks/debug"
)
func getImageInfo(path string) (event.FileInfo, error) {
var info event.FileInfo
file, err := os.Open(path)
if err != nil {
return info, fmt.Errorf("failed to open image to get info: %w", err)
}
cfg, _, err := image.DecodeConfig(file)
if err != nil {
return info, fmt.Errorf("failed to get image info: %w", err)
}
info.Width = cfg.Width
info.Height = cfg.Height
return info, nil
}
func getFFProbeInfo(mimeClass, path string) (msgtype event.MessageType, info event.FileInfo, err error) {
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
var probedInfo *ffprobe.ProbeData
probedInfo, err = ffprobe.ProbeURL(ctx, path)
if err != nil {
err = fmt.Errorf("failed to get %s info with ffprobe: %w", mimeClass, err)
return
}
if mimeClass == "audio" {
msgtype = event.MsgAudio
stream := probedInfo.FirstAudioStream()
if stream != nil {
info.Duration = int(stream.DurationTs)
}
} else {
msgtype = event.MsgVideo
stream := probedInfo.FirstVideoStream()
if stream != nil {
info.Duration = int(stream.DurationTs)
info.Width = stream.Width
info.Height = stream.Height
}
}
return
}
func getMediaInfo(path string) (msgtype event.MessageType, info event.FileInfo, err error) {
var mime *mimetype.MIME
mime, err = mimetype.DetectFile(path)
if err != nil {
err = fmt.Errorf("failed to get content type: %w", err)
return
}
mimeClass := strings.SplitN(mime.String(), "/", 2)[0]
switch mimeClass {
case "image":
msgtype = event.MsgImage
info, err = getImageInfo(path)
if err != nil {
debug.Printf("Failed to get image info for %s: %v", err)
err = nil
}
case "audio", "video":
msgtype, info, err = getFFProbeInfo(mimeClass, path)
if err != nil {
debug.Printf("Failed to get ffprobe info for %s: %v", err)
err = nil
}
default:
msgtype = event.MsgFile
}
info.MimeType = mime.String()
return
}

View file

@ -1,44 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package muksevt
import (
"encoding/gob"
"reflect"
"maunium.net/go/mautrix/event"
)
var EventBadEncrypted = event.Type{Type: "net.maunium.gomuks.bad_encrypted", Class: event.MessageEventType}
var EventEncryptionUnsupported = event.Type{Type: "net.maunium.gomuks.encryption_unsupported", Class: event.MessageEventType}
type BadEncryptedContent struct {
Original *event.EncryptedEventContent `json:"-"`
Reason string `json:"-"`
}
type EncryptionUnsupportedContent struct {
Original *event.EncryptedEventContent `json:"-"`
}
func init() {
gob.Register(&BadEncryptedContent{})
gob.Register(&EncryptionUnsupportedContent{})
event.TypeMap[EventBadEncrypted] = reflect.TypeOf(&BadEncryptedContent{})
event.TypeMap[EventEncryptionUnsupported] = reflect.TypeOf(&EncryptionUnsupportedContent{})
}

View file

@ -1,53 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package muksevt
import (
"maunium.net/go/mautrix/event"
)
type Event struct {
*event.Event
Gomuks GomuksContent `json:"-"`
}
func (evt *Event) SomewhatDangerousCopy() *Event {
base := *evt.Event
content := *base.Content.Parsed.(*event.MessageEventContent)
evt.Content.Parsed = &content
return &Event{
Event: &base,
Gomuks: evt.Gomuks,
}
}
func Wrap(event *event.Event) *Event {
return &Event{Event: event}
}
type OutgoingState int
const (
StateDefault OutgoingState = iota
StateLocalEcho
StateSendFail
)
type GomuksContent struct {
OutgoingState OutgoingState
Edits []*Event
}

View file

@ -1,15 +0,0 @@
// This contains no-op stubs of the methods in crypto.go for non-cgo builds with crypto disabled.
// +build !cgo
package matrix
func isBadEncryptError(err error) bool {
return false
}
func (c *Container) initCrypto() error {
return nil
}
func (c *Container) cryptoOnLogin() {}

View file

@ -1,2 +0,0 @@
// Package rooms contains a representation for Matrix rooms and utilities to parse state events.
package rooms

View file

@ -1,714 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package rooms
import (
"compress/gzip"
"encoding/gob"
"encoding/json"
"fmt"
"os"
"time"
sync "github.com/sasha-s/go-deadlock"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/debug"
)
func init() {
gob.Register(map[string]interface{}{})
gob.Register([]interface{}{})
}
type RoomNameSource int
const (
UnknownRoomName RoomNameSource = iota
MemberRoomName
CanonicalAliasRoomName
ExplicitRoomName
)
// RoomTag is a tag given to a specific room.
type RoomTag struct {
// The name of the tag.
Tag string
// The order of the tag.
Order json.Number
}
type UnreadMessage struct {
EventID id.EventID
Counted bool
Highlight bool
}
type Member struct {
event.MemberEventContent
// The user who sent the membership event
Sender id.UserID `json:"-"`
}
// Room represents a single Matrix room.
type Room struct {
// The room ID.
ID id.RoomID
// Whether or not the user has left the room.
HasLeft bool
// Whether or not the room is encrypted.
Encrypted bool
// The first batch of events that has been fetched for this room.
// Used for fetching additional history.
PrevBatch string
// The last_batch field from the most recent sync. Used for fetching member lists.
LastPrevBatch string
// The MXID of the user whose session this room was created for.
SessionUserID id.UserID
SessionMember *Member
// The number of unread messages that were notified about.
UnreadMessages []UnreadMessage
unreadCountCache *int
highlightCache *bool
lastMarkedRead id.EventID
// Whether or not this room is marked as a direct chat.
IsDirect bool
OtherUser id.UserID
// List of tags given to this room.
RawTags []RoomTag
// Timestamp of previously received actual message.
LastReceivedMessage time.Time
// The lazy loading summary for this room.
Summary mautrix.LazyLoadSummary
// Whether or not the members for this room have been fetched from the server.
MembersFetched bool
// Room state cache.
state map[event.Type]map[string]*event.Event
// MXID -> Member cache calculated from membership events.
memberCache map[id.UserID]*Member
exMemberCache map[id.UserID]*Member
// The first two non-SessionUserID members in the room. Calculated at
// the same time as memberCache.
firstMemberCache *Member
secondMemberCache *Member
// The name of the room. Calculated from the state event name,
// canonical_alias or alias or the member cache.
NameCache string
// The event type from which the name cache was calculated from.
nameCacheSource RoomNameSource
// The topic of the room. Directly fetched from the m.room.topic state event.
topicCache string
// The canonical alias of the room. Directly fetched from the m.room.canonical_alias state event.
CanonicalAliasCache id.RoomAlias
// Whether or not the room has been tombstoned.
replacedCache bool
// The room ID that replaced this room.
replacedByCache *id.RoomID
// Path for state store file.
path string
// Room cache object
cache *RoomCache
// Lock for state and other room stuff.
lock sync.RWMutex
// Pre/post un/load hooks
preUnload func() bool
preLoad func() bool
postUnload func()
postLoad func()
// Whether or not the room state has changed
changed bool
// Room state cache linked list.
prev *Room
next *Room
touch int64
}
func debugPrintError(fn func() error, message string) {
if err := fn(); err != nil {
debug.Printf("%s: %v", message, err)
}
}
func (room *Room) Loaded() bool {
return room.state != nil
}
func (room *Room) Load() {
room.cache.TouchNode(room)
if room.Loaded() {
return
}
if room.preLoad != nil && !room.preLoad() {
return
}
room.lock.Lock()
room.load()
room.lock.Unlock()
if room.postLoad != nil {
room.postLoad()
}
}
func (room *Room) load() {
if room.Loaded() {
return
}
debug.Print("Loading state for room", room.ID, "from disk")
room.state = make(map[event.Type]map[string]*event.Event)
file, err := os.OpenFile(room.path, os.O_RDONLY, 0600)
if err != nil {
if !os.IsNotExist(err) {
debug.Print("Failed to open room state file for reading:", err)
} else {
debug.Print("Room state file for", room.ID, "does not exist")
}
return
}
defer debugPrintError(file.Close, "Failed to close room state file after reading")
cmpReader, err := gzip.NewReader(file)
if err != nil {
debug.Print("Failed to open room state gzip reader:", err)
return
}
defer debugPrintError(cmpReader.Close, "Failed to close room state gzip reader")
dec := gob.NewDecoder(cmpReader)
if err = dec.Decode(&room.state); err != nil {
debug.Print("Failed to decode room state:", err)
}
room.changed = false
}
func (room *Room) Touch() {
room.cache.TouchNode(room)
}
func (room *Room) Unload() bool {
if room.preUnload != nil && !room.preUnload() {
return false
}
debug.Print("Unloading", room.ID)
room.Save()
room.state = nil
room.memberCache = nil
room.exMemberCache = nil
room.firstMemberCache = nil
room.secondMemberCache = nil
if room.postUnload != nil {
room.postUnload()
}
return true
}
func (room *Room) SetPreUnload(fn func() bool) {
room.preUnload = fn
}
func (room *Room) SetPreLoad(fn func() bool) {
room.preLoad = fn
}
func (room *Room) SetPostUnload(fn func()) {
room.postUnload = fn
}
func (room *Room) SetPostLoad(fn func()) {
room.postLoad = fn
}
func (room *Room) Save() {
if !room.Loaded() {
debug.Print("Failed to save room", room.ID, "state: room not loaded")
return
}
if !room.changed {
debug.Print("Not saving", room.ID, "as state hasn't changed")
return
}
debug.Print("Saving state for room", room.ID, "to disk")
file, err := os.OpenFile(room.path, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
debug.Print("Failed to open room state file for writing:", err)
return
}
defer debugPrintError(file.Close, "Failed to close room state file after writing")
cmpWriter := gzip.NewWriter(file)
defer debugPrintError(cmpWriter.Close, "Failed to close room state gzip writer")
enc := gob.NewEncoder(cmpWriter)
room.lock.RLock()
defer room.lock.RUnlock()
if err := enc.Encode(&room.state); err != nil {
debug.Print("Failed to encode room state:", err)
}
}
// MarkRead clears the new message statuses on this room.
func (room *Room) MarkRead(eventID id.EventID) bool {
room.lock.Lock()
defer room.lock.Unlock()
if room.lastMarkedRead == eventID {
return false
}
room.lastMarkedRead = eventID
readToIndex := -1
for index, unreadMessage := range room.UnreadMessages {
if unreadMessage.EventID == eventID {
readToIndex = index
}
}
if readToIndex >= 0 {
room.UnreadMessages = room.UnreadMessages[readToIndex+1:]
room.highlightCache = nil
room.unreadCountCache = nil
}
return true
}
func (room *Room) UnreadCount() int {
room.lock.Lock()
defer room.lock.Unlock()
if room.unreadCountCache == nil {
room.unreadCountCache = new(int)
for _, unreadMessage := range room.UnreadMessages {
if unreadMessage.Counted {
*room.unreadCountCache++
}
}
}
return *room.unreadCountCache
}
func (room *Room) Highlighted() bool {
room.lock.Lock()
defer room.lock.Unlock()
if room.highlightCache == nil {
room.highlightCache = new(bool)
for _, unreadMessage := range room.UnreadMessages {
if unreadMessage.Highlight {
*room.highlightCache = true
break
}
}
}
return *room.highlightCache
}
func (room *Room) HasNewMessages() bool {
return len(room.UnreadMessages) > 0
}
func (room *Room) AddUnread(eventID id.EventID, counted, highlight bool) {
room.lock.Lock()
defer room.lock.Unlock()
room.UnreadMessages = append(room.UnreadMessages, UnreadMessage{
EventID: eventID,
Counted: counted,
Highlight: highlight,
})
if counted {
if room.unreadCountCache == nil {
room.unreadCountCache = new(int)
}
*room.unreadCountCache++
}
if highlight {
if room.highlightCache == nil {
room.highlightCache = new(bool)
}
*room.highlightCache = true
}
}
var (
tagDirect = RoomTag{"net.maunium.gomuks.fake.direct", "0.5"}
tagInvite = RoomTag{"net.maunium.gomuks.fake.invite", "0.5"}
tagDefault = RoomTag{"", "0.5"}
tagLeave = RoomTag{"net.maunium.gomuks.fake.leave", "0.5"}
)
func (room *Room) Tags() []RoomTag {
room.lock.RLock()
defer room.lock.RUnlock()
if len(room.RawTags) == 0 {
if room.IsDirect {
return []RoomTag{tagDirect}
} else if room.SessionMember != nil && room.SessionMember.Membership == event.MembershipInvite {
return []RoomTag{tagInvite}
} else if room.SessionMember != nil && room.SessionMember.Membership != event.MembershipJoin {
return []RoomTag{tagLeave}
}
return []RoomTag{tagDefault}
}
return room.RawTags
}
func (room *Room) UpdateSummary(summary mautrix.LazyLoadSummary) {
if summary.JoinedMemberCount != nil {
room.Summary.JoinedMemberCount = summary.JoinedMemberCount
}
if summary.InvitedMemberCount != nil {
room.Summary.InvitedMemberCount = summary.InvitedMemberCount
}
if summary.Heroes != nil {
room.Summary.Heroes = summary.Heroes
}
if room.nameCacheSource <= MemberRoomName {
room.NameCache = ""
}
}
// UpdateState updates the room's current state with the given Event. This will clobber events based
// on the type/state_key combination.
func (room *Room) UpdateState(evt *event.Event) {
if evt.StateKey == nil {
panic("Tried to UpdateState() event with no state key.")
}
room.Load()
room.lock.Lock()
defer room.lock.Unlock()
room.changed = true
_, exists := room.state[evt.Type]
if !exists {
room.state[evt.Type] = make(map[string]*event.Event)
}
switch content := evt.Content.Parsed.(type) {
case *event.RoomNameEventContent:
room.NameCache = content.Name
room.nameCacheSource = ExplicitRoomName
case *event.CanonicalAliasEventContent:
if room.nameCacheSource <= CanonicalAliasRoomName {
room.NameCache = string(content.Alias)
room.nameCacheSource = CanonicalAliasRoomName
}
room.CanonicalAliasCache = content.Alias
case *event.MemberEventContent:
if room.nameCacheSource <= MemberRoomName {
room.NameCache = ""
}
room.updateMemberState(id.UserID(evt.GetStateKey()), evt.Sender, content)
case *event.TopicEventContent:
room.topicCache = content.Topic
case *event.EncryptionEventContent:
if content.Algorithm == id.AlgorithmMegolmV1 {
room.Encrypted = true
}
}
if evt.Type != event.StateMember {
debug.Printf("Updating state %s#%s for %s", evt.Type.String(), evt.GetStateKey(), room.ID)
}
room.state[evt.Type][*evt.StateKey] = evt
}
func (room *Room) updateMemberState(userID, sender id.UserID, content *event.MemberEventContent) {
if userID == room.SessionUserID {
debug.Print("Updating session user state:", content)
room.SessionMember = room.eventToMember(userID, sender, content)
}
if room.memberCache != nil {
member := room.eventToMember(userID, sender, content)
if member.Membership.IsInviteOrJoin() {
existingMember, ok := room.memberCache[userID]
if ok {
*existingMember = *member
} else {
delete(room.exMemberCache, userID)
room.memberCache[userID] = member
room.updateNthMemberCache(userID, member)
}
} else {
existingExMember, ok := room.exMemberCache[userID]
if ok {
*existingExMember = *member
} else {
delete(room.memberCache, userID)
room.exMemberCache[userID] = member
}
}
}
}
// GetStateEvent returns the state event for the given type/state_key combo, or nil.
func (room *Room) GetStateEvent(eventType event.Type, stateKey string) *event.Event {
room.Load()
room.lock.RLock()
defer room.lock.RUnlock()
stateEventMap, _ := room.state[eventType]
evt, _ := stateEventMap[stateKey]
return evt
}
// getStateEvents returns the state events for the given type.
func (room *Room) getStateEvents(eventType event.Type) map[string]*event.Event {
stateEventMap, _ := room.state[eventType]
return stateEventMap
}
// GetTopic returns the topic of the room.
func (room *Room) GetTopic() string {
if len(room.topicCache) == 0 {
topicEvt := room.GetStateEvent(event.StateTopic, "")
if topicEvt != nil {
room.topicCache = topicEvt.Content.AsTopic().Topic
}
}
return room.topicCache
}
func (room *Room) GetCanonicalAlias() id.RoomAlias {
if len(room.CanonicalAliasCache) == 0 {
canonicalAliasEvt := room.GetStateEvent(event.StateCanonicalAlias, "")
if canonicalAliasEvt != nil {
room.CanonicalAliasCache = canonicalAliasEvt.Content.AsCanonicalAlias().Alias
} else {
room.CanonicalAliasCache = "-"
}
}
if room.CanonicalAliasCache == "-" {
return ""
}
return room.CanonicalAliasCache
}
// updateNameFromNameEvent updates the room display name to be the name set in the name event.
func (room *Room) updateNameFromNameEvent() {
nameEvt := room.GetStateEvent(event.StateRoomName, "")
if nameEvt != nil {
room.NameCache = nameEvt.Content.AsRoomName().Name
}
}
// updateNameFromMembers updates the room display name based on the members in this room.
//
// The room name depends on the number of users:
// Less than two users -> "Empty room"
// Exactly two users -> The display name of the other user.
// More than two users -> The display name of one of the other users, followed
// by "and X others", where X is the number of users
// excluding the local user and the named user.
func (room *Room) updateNameFromMembers() {
members := room.GetMembers()
if len(members) <= 1 {
room.NameCache = "Empty room"
} else if room.firstMemberCache == nil {
room.NameCache = "Room"
} else if len(members) == 2 {
room.NameCache = room.firstMemberCache.Displayname
} else if len(members) == 3 && room.secondMemberCache != nil {
room.NameCache = fmt.Sprintf("%s and %s", room.firstMemberCache.Displayname, room.secondMemberCache.Displayname)
} else {
members := room.firstMemberCache.Displayname
count := len(members) - 2
if room.secondMemberCache != nil {
members += ", " + room.secondMemberCache.Displayname
count--
}
room.NameCache = fmt.Sprintf("%s and %d others", members, count)
}
}
// updateNameCache updates the room display name based on the room state in the order
// specified in spec section 11.2.2.5.
func (room *Room) updateNameCache() {
if len(room.NameCache) == 0 {
room.updateNameFromNameEvent()
room.nameCacheSource = ExplicitRoomName
}
if len(room.NameCache) == 0 {
room.NameCache = string(room.GetCanonicalAlias())
room.nameCacheSource = CanonicalAliasRoomName
}
if len(room.NameCache) == 0 {
room.updateNameFromMembers()
room.nameCacheSource = MemberRoomName
}
}
// GetTitle returns the display name of the room.
//
// The display name is returned from the cache.
// If the cache is empty, it is updated first.
func (room *Room) GetTitle() string {
room.updateNameCache()
return room.NameCache
}
func (room *Room) IsReplaced() bool {
if room.replacedByCache == nil {
evt := room.GetStateEvent(event.StateTombstone, "")
var replacement id.RoomID
if evt != nil {
content, ok := evt.Content.Parsed.(*event.TombstoneEventContent)
if ok {
replacement = content.ReplacementRoom
}
}
room.replacedCache = evt != nil
room.replacedByCache = &replacement
}
return room.replacedCache
}
func (room *Room) ReplacedBy() id.RoomID {
if room.replacedByCache == nil {
room.IsReplaced()
}
return *room.replacedByCache
}
func (room *Room) eventToMember(userID, sender id.UserID, member *event.MemberEventContent) *Member {
if len(member.Displayname) == 0 {
member.Displayname = string(userID)
}
return &Member{
MemberEventContent: *member,
Sender: sender,
}
}
func (room *Room) updateNthMemberCache(userID id.UserID, member *Member) {
if userID != room.SessionUserID {
if room.firstMemberCache == nil {
room.firstMemberCache = member
} else if room.secondMemberCache == nil {
room.secondMemberCache = member
}
}
}
// createMemberCache caches all member events into a easily processable MXID -> *Member map.
func (room *Room) createMemberCache() map[id.UserID]*Member {
if len(room.memberCache) > 0 {
return room.memberCache
}
cache := make(map[id.UserID]*Member)
exCache := make(map[id.UserID]*Member)
room.lock.RLock()
memberEvents := room.getStateEvents(event.StateMember)
room.firstMemberCache = nil
room.secondMemberCache = nil
if memberEvents != nil {
for userIDStr, evt := range memberEvents {
userID := id.UserID(userIDStr)
member := room.eventToMember(userID, evt.Sender, evt.Content.AsMember())
if member.Membership.IsInviteOrJoin() {
cache[userID] = member
room.updateNthMemberCache(userID, member)
} else {
exCache[userID] = member
}
if userID == room.SessionUserID {
room.SessionMember = member
}
}
}
if len(room.Summary.Heroes) > 1 {
room.firstMemberCache, _ = cache[room.Summary.Heroes[0]]
}
if len(room.Summary.Heroes) > 2 {
room.secondMemberCache, _ = cache[room.Summary.Heroes[1]]
}
room.lock.RUnlock()
room.lock.Lock()
room.memberCache = cache
room.exMemberCache = exCache
room.lock.Unlock()
return cache
}
// GetMembers returns the members in this room.
//
// The members are returned from the cache.
// If the cache is empty, it is updated first.
func (room *Room) GetMembers() map[id.UserID]*Member {
room.Load()
room.createMemberCache()
return room.memberCache
}
func (room *Room) GetMemberList() []id.UserID {
members := room.GetMembers()
memberList := make([]id.UserID, len(members))
index := 0
for userID, _ := range members {
memberList[index] = userID
index++
}
return memberList
}
// GetMember returns the member with the given MXID.
// If the member doesn't exist, nil is returned.
func (room *Room) GetMember(userID id.UserID) *Member {
if userID == room.SessionUserID && room.SessionMember != nil {
return room.SessionMember
}
room.Load()
room.createMemberCache()
room.lock.RLock()
member, ok := room.memberCache[userID]
if ok {
room.lock.RUnlock()
return member
}
exMember, ok := room.exMemberCache[userID]
if ok {
room.lock.RUnlock()
return exMember
}
room.lock.RUnlock()
return nil
}
func (room *Room) GetMemberCount() int {
if room.memberCache == nil && room.Summary.JoinedMemberCount != nil {
return *room.Summary.JoinedMemberCount
}
return len(room.GetMembers())
}
// GetSessionOwner returns the ID of the user whose session this room was created for.
func (room *Room) GetOwnDisplayname() string {
member := room.GetMember(room.SessionUserID)
if member != nil {
return member.Displayname
}
return ""
}
// NewRoom creates a new Room with the given ID
func NewRoom(roomID id.RoomID, cache *RoomCache) *Room {
return &Room{
ID: roomID,
state: make(map[event.Type]map[string]*event.Event),
path: cache.roomPath(roomID),
cache: cache,
SessionUserID: cache.getOwner(),
}
}

View file

@ -1,376 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package rooms
import (
"compress/gzip"
"encoding/gob"
"fmt"
"os"
"path/filepath"
"strings"
"time"
sync "github.com/sasha-s/go-deadlock"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/debug"
)
// RoomCache contains room state info in a hashmap and linked list.
type RoomCache struct {
sync.Mutex
listPath string
directory string
maxSize int
maxAge int64
getOwner func() id.UserID
noUnload bool
Map map[id.RoomID]*Room
head *Room
tail *Room
size int
}
func NewRoomCache(listPath, directory string, maxSize int, maxAge int64, getOwner func() id.UserID) *RoomCache {
return &RoomCache{
listPath: listPath,
directory: directory,
maxSize: maxSize,
maxAge: maxAge,
getOwner: getOwner,
Map: make(map[id.RoomID]*Room),
}
}
func (cache *RoomCache) DisableUnloading() {
cache.noUnload = true
}
func (cache *RoomCache) EnableUnloading() {
cache.noUnload = false
}
func (cache *RoomCache) IsEncrypted(roomID id.RoomID) bool {
room := cache.Get(roomID)
return room != nil && room.Encrypted
}
func (cache *RoomCache) GetEncryptionEvent(roomID id.RoomID) *event.EncryptionEventContent {
room := cache.Get(roomID)
evt := room.GetStateEvent(event.StateEncryption, "")
if evt == nil {
return nil
}
content, ok := evt.Content.Parsed.(*event.EncryptionEventContent)
if !ok {
return nil
}
return content
}
func (cache *RoomCache) FindSharedRooms(userID id.UserID) (shared []id.RoomID) {
// FIXME this disables unloading so TouchNode wouldn't try to double-lock
cache.DisableUnloading()
cache.Lock()
for _, room := range cache.Map {
if !room.Encrypted {
continue
}
member, ok := room.GetMembers()[userID]
if ok && member.Membership == event.MembershipJoin {
shared = append(shared, room.ID)
}
}
cache.Unlock()
cache.EnableUnloading()
return
}
func (cache *RoomCache) LoadList() error {
cache.Lock()
defer cache.Unlock()
// Open room list file
file, err := os.OpenFile(cache.listPath, os.O_RDONLY, 0600)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to open room list file for reading: %w", err)
}
defer debugPrintError(file.Close, "Failed to close room list file after reading")
// Open gzip reader for room list file
cmpReader, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to read gzip room list: %w", err)
}
defer debugPrintError(cmpReader.Close, "Failed to close room list gzip reader")
// Open gob decoder for gzip reader
dec := gob.NewDecoder(cmpReader)
// Read number of items in list
var size int
err = dec.Decode(&size)
if err != nil {
return fmt.Errorf("failed to read size of room list: %w", err)
}
// Read list
cache.Map = make(map[id.RoomID]*Room, size)
for i := 0; i < size; i++ {
room := &Room{}
err = dec.Decode(room)
if err != nil {
debug.Printf("Failed to decode %dth room list entry: %v", i+1, err)
continue
}
room.path = cache.roomPath(room.ID)
room.cache = cache
cache.Map[room.ID] = room
}
return nil
}
func (cache *RoomCache) SaveLoadedRooms() {
cache.Lock()
cache.clean(false)
for node := cache.head; node != nil; node = node.prev {
node.Save()
}
cache.Unlock()
}
func (cache *RoomCache) SaveList() error {
cache.Lock()
defer cache.Unlock()
debug.Print("Saving room list...")
// Open room list file
file, err := os.OpenFile(cache.listPath, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return fmt.Errorf("failed to open room list file for writing: %w", err)
}
defer debugPrintError(file.Close, "Failed to close room list file after writing")
// Open gzip writer for room list file
cmpWriter := gzip.NewWriter(file)
defer debugPrintError(cmpWriter.Close, "Failed to close room list gzip writer")
// Open gob encoder for gzip writer
enc := gob.NewEncoder(cmpWriter)
// Write number of items in list
err = enc.Encode(len(cache.Map))
if err != nil {
return fmt.Errorf("failed to write size of room list: %w", err)
}
// Write list
for _, node := range cache.Map {
err = enc.Encode(node)
if err != nil {
debug.Printf("Failed to encode room list entry of %s: %v", node.ID, err)
}
}
debug.Print("Room list saved to", cache.listPath, len(cache.Map), cache.size)
return nil
}
func (cache *RoomCache) Touch(roomID id.RoomID) {
cache.Lock()
node, ok := cache.Map[roomID]
if !ok || node == nil {
cache.Unlock()
return
}
cache.touch(node)
cache.Unlock()
}
func (cache *RoomCache) TouchNode(node *Room) {
if cache.noUnload || node.touch+2 > time.Now().Unix() {
return
}
cache.Lock()
cache.touch(node)
cache.Unlock()
}
func (cache *RoomCache) touch(node *Room) {
if node == cache.head {
return
}
debug.Print("Touching", node.ID)
cache.llPop(node)
cache.llPush(node)
node.touch = time.Now().Unix()
}
func (cache *RoomCache) Get(roomID id.RoomID) *Room {
cache.Lock()
node := cache.get(roomID)
cache.Unlock()
return node
}
func (cache *RoomCache) GetOrCreate(roomID id.RoomID) *Room {
cache.Lock()
node := cache.get(roomID)
if node == nil {
node = cache.newRoom(roomID)
cache.llPush(node)
}
cache.Unlock()
return node
}
func (cache *RoomCache) get(roomID id.RoomID) *Room {
node, ok := cache.Map[roomID]
if ok && node != nil {
return node
}
return nil
}
func (cache *RoomCache) Put(room *Room) {
cache.Lock()
node := cache.get(room.ID)
if node != nil {
cache.touch(node)
} else {
cache.Map[room.ID] = room
if room.Loaded() {
cache.llPush(room)
}
node = room
}
cache.Unlock()
node.Save()
}
func (cache *RoomCache) roomPath(roomID id.RoomID) string {
escapedRoomID := strings.ReplaceAll(strings.ReplaceAll(string(roomID), "%", "%25"), "/", "%2F")
return filepath.Join(cache.directory, escapedRoomID+".gob.gz")
}
func (cache *RoomCache) Load(roomID id.RoomID) *Room {
cache.Lock()
defer cache.Unlock()
node, ok := cache.Map[roomID]
if ok {
return node
}
node = NewRoom(roomID, cache)
node.Load()
return node
}
func (cache *RoomCache) llPop(node *Room) {
if node.prev == nil && node.next == nil {
return
}
if node.prev != nil {
node.prev.next = node.next
}
if node.next != nil {
node.next.prev = node.prev
}
if node == cache.tail {
cache.tail = node.next
}
if node == cache.head {
cache.head = node.prev
}
node.next = nil
node.prev = nil
cache.size--
}
func (cache *RoomCache) llPush(node *Room) {
if node.next != nil || node.prev != nil {
debug.PrintStack()
debug.Print("Tried to llPush node that is already in stack")
return
}
if node == cache.head {
return
}
if cache.head != nil {
cache.head.next = node
}
node.prev = cache.head
node.next = nil
cache.head = node
if cache.tail == nil {
cache.tail = node
}
cache.size++
cache.clean(false)
}
func (cache *RoomCache) ForceClean() {
cache.Lock()
cache.clean(true)
cache.Unlock()
}
func (cache *RoomCache) clean(force bool) {
if cache.noUnload && !force {
return
}
origSize := cache.size
maxTS := time.Now().Unix() - cache.maxAge
for cache.size > cache.maxSize {
if cache.tail.touch > maxTS && !force {
break
}
ok := cache.tail.Unload()
node := cache.tail
cache.llPop(node)
if !ok {
debug.Print("Unload returned false, pushing node back")
cache.llPush(node)
}
}
if cleaned := origSize - cache.size; cleaned > 0 {
debug.Print("Cleaned", cleaned, "rooms")
}
}
func (cache *RoomCache) Unload(node *Room) {
cache.Lock()
defer cache.Unlock()
cache.llPop(node)
ok := node.Unload()
if !ok {
debug.Print("Unload returned false, pushing node back")
cache.llPush(node)
}
}
func (cache *RoomCache) newRoom(roomID id.RoomID) *Room {
node := NewRoom(roomID, cache)
cache.Map[node.ID] = node
return node
}

View file

@ -1,268 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Based on https://github.com/matrix-org/mautrix/blob/master/sync.go
package matrix
import (
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
ifc "maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/matrix/rooms"
)
type GomuksSyncer struct {
rooms *rooms.RoomCache
globalListeners []mautrix.SyncHandler
listeners map[event.Type][]mautrix.EventHandler // event type to listeners array
FirstSyncDone bool
InitDoneCallback func()
FirstDoneCallback func()
Progress ifc.SyncingModal
}
// NewGomuksSyncer returns an instantiated GomuksSyncer
func NewGomuksSyncer(rooms *rooms.RoomCache) *GomuksSyncer {
return &GomuksSyncer{
rooms: rooms,
globalListeners: []mautrix.SyncHandler{},
listeners: make(map[event.Type][]mautrix.EventHandler),
FirstSyncDone: false,
Progress: StubSyncingModal{},
}
}
// ProcessResponse processes a Matrix sync response.
func (s *GomuksSyncer) ProcessResponse(res *mautrix.RespSync, since string) (err error) {
if since == "" {
s.rooms.DisableUnloading()
}
debug.Print("Received sync response")
s.Progress.SetMessage("Processing sync response")
steps := len(res.Rooms.Join) + len(res.Rooms.Invite) + len(res.Rooms.Leave)
s.Progress.SetSteps(steps + 2 + len(s.globalListeners))
wait := &sync.WaitGroup{}
callback := func() {
wait.Done()
s.Progress.Step()
}
wait.Add(len(s.globalListeners))
s.notifyGlobalListeners(res, since, callback)
wait.Wait()
s.processSyncEvents(nil, res.Presence.Events, mautrix.EventSourcePresence)
s.Progress.Step()
s.processSyncEvents(nil, res.AccountData.Events, mautrix.EventSourceAccountData)
s.Progress.Step()
wait.Add(steps)
for roomID, roomData := range res.Rooms.Join {
go s.processJoinedRoom(roomID, roomData, callback)
}
for roomID, roomData := range res.Rooms.Invite {
go s.processInvitedRoom(roomID, roomData, callback)
}
for roomID, roomData := range res.Rooms.Leave {
go s.processLeftRoom(roomID, roomData, callback)
}
wait.Wait()
s.Progress.SetMessage("Finishing sync")
if since == "" && s.InitDoneCallback != nil {
s.InitDoneCallback()
s.rooms.EnableUnloading()
}
if !s.FirstSyncDone && s.FirstDoneCallback != nil {
s.FirstDoneCallback()
}
s.FirstSyncDone = true
return
}
func (s *GomuksSyncer) notifyGlobalListeners(res *mautrix.RespSync, since string, callback func()) {
for _, listener := range s.globalListeners {
go func(listener mautrix.SyncHandler) {
listener(res, since)
callback()
}(listener)
}
}
func (s *GomuksSyncer) processJoinedRoom(roomID id.RoomID, roomData mautrix.SyncJoinedRoom, callback func()) {
defer debug.Recover()
room := s.rooms.GetOrCreate(roomID)
room.UpdateSummary(roomData.Summary)
s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceJoin|mautrix.EventSourceState)
s.processSyncEvents(room, roomData.Timeline.Events, mautrix.EventSourceJoin|mautrix.EventSourceTimeline)
s.processSyncEvents(room, roomData.Ephemeral.Events, mautrix.EventSourceJoin|mautrix.EventSourceEphemeral)
s.processSyncEvents(room, roomData.AccountData.Events, mautrix.EventSourceJoin|mautrix.EventSourceAccountData)
if len(room.PrevBatch) == 0 {
room.PrevBatch = roomData.Timeline.PrevBatch
}
room.LastPrevBatch = roomData.Timeline.PrevBatch
callback()
}
func (s *GomuksSyncer) processInvitedRoom(roomID id.RoomID, roomData mautrix.SyncInvitedRoom, callback func()) {
defer debug.Recover()
room := s.rooms.GetOrCreate(roomID)
room.UpdateSummary(roomData.Summary)
s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceInvite|mautrix.EventSourceState)
callback()
}
func (s *GomuksSyncer) processLeftRoom(roomID id.RoomID, roomData mautrix.SyncLeftRoom, callback func()) {
defer debug.Recover()
room := s.rooms.GetOrCreate(roomID)
room.HasLeft = true
room.UpdateSummary(roomData.Summary)
s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceLeave|mautrix.EventSourceState)
s.processSyncEvents(room, roomData.Timeline.Events, mautrix.EventSourceLeave|mautrix.EventSourceTimeline)
if len(room.PrevBatch) == 0 {
room.PrevBatch = roomData.Timeline.PrevBatch
}
room.LastPrevBatch = roomData.Timeline.PrevBatch
callback()
}
func (s *GomuksSyncer) processSyncEvents(room *rooms.Room, events []*event.Event, source mautrix.EventSource) {
for _, evt := range events {
s.processSyncEvent(room, evt, source)
}
}
func (s *GomuksSyncer) processSyncEvent(room *rooms.Room, evt *event.Event, source mautrix.EventSource) {
if room != nil {
evt.RoomID = room.ID
}
// Ensure the type class is correct. It's safe to mutate since it's not a pointer.
// Listeners are keyed by type structs, which means only the correct class will pass.
switch {
case evt.StateKey != nil:
evt.Type.Class = event.StateEventType
case source == mautrix.EventSourcePresence, source&mautrix.EventSourceEphemeral != 0:
evt.Type.Class = event.EphemeralEventType
case source&mautrix.EventSourceAccountData != 0:
evt.Type.Class = event.AccountDataEventType
case source == mautrix.EventSourceToDevice:
evt.Type.Class = event.ToDeviceEventType
default:
evt.Type.Class = event.MessageEventType
}
err := evt.Content.ParseRaw(evt.Type)
if err != nil {
debug.Printf("Failed to unmarshal content of event %s (type %s) by %s in %s: %v\n%s", evt.ID, evt.Type.Repr(), evt.Sender, evt.RoomID, err, string(evt.Content.VeryRaw))
// TODO might be good to let these pass to allow handling invalid events too
return
}
if room != nil && evt.Type.IsState() {
room.UpdateState(evt)
}
s.notifyListeners(source, evt)
}
// OnEventType allows callers to be notified when there are new events for the given event type.
// There are no duplicate checks.
func (s *GomuksSyncer) OnEventType(eventType event.Type, callback mautrix.EventHandler) {
_, exists := s.listeners[eventType]
if !exists {
s.listeners[eventType] = []mautrix.EventHandler{}
}
s.listeners[eventType] = append(s.listeners[eventType], callback)
}
func (s *GomuksSyncer) OnSync(callback mautrix.SyncHandler) {
s.globalListeners = append(s.globalListeners, callback)
}
func (s *GomuksSyncer) notifyListeners(source mautrix.EventSource, evt *event.Event) {
listeners, exists := s.listeners[evt.Type]
if !exists {
return
}
for _, fn := range listeners {
fn(source, evt)
}
}
// OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error.
func (s *GomuksSyncer) OnFailedSync(res *mautrix.RespSync, err error) (time.Duration, error) {
debug.Printf("Sync failed: %v", err)
return 10 * time.Second, nil
}
// GetFilterJSON returns a filter with a timeline limit of 50.
func (s *GomuksSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
stateEvents := []event.Type{
event.StateMember,
event.StateRoomName,
event.StateTopic,
event.StateCanonicalAlias,
event.StatePowerLevels,
event.StateTombstone,
event.StateEncryption,
}
messageEvents := []event.Type{
event.EventMessage,
event.EventRedaction,
event.EventEncrypted,
event.EventSticker,
event.EventReaction,
}
return &mautrix.Filter{
Room: mautrix.RoomFilter{
IncludeLeave: false,
State: mautrix.FilterPart{
LazyLoadMembers: true,
Types: stateEvents,
},
Timeline: mautrix.FilterPart{
LazyLoadMembers: true,
Types: append(messageEvents, stateEvents...),
Limit: 50,
},
Ephemeral: mautrix.FilterPart{
Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt},
},
AccountData: mautrix.FilterPart{
Types: []event.Type{event.AccountDataRoomTags},
},
},
AccountData: mautrix.FilterPart{
Types: []event.Type{event.AccountDataPushRules, event.AccountDataDirectChats, AccountDataGomuksPreferences},
},
Presence: mautrix.FilterPart{
NotTypes: []event.Type{event.NewEventType("*")},
},
}
}

View file

@ -1,115 +0,0 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package matrix
import (
"context"
"errors"
"net/http"
"net/url"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/lib/open"
)
const uiaFallbackPage = `<!DOCTYPE html>
<html lang="en">
<head>
<title>gomuks user-interactive auth</title>
<meta charset="utf-8"/>
<style>
body {
text-align: center;
}
</style>
</head>
<body>
<h2>Please complete the login in the popup window</h2>
<p>Keep this page open while logging in, it will close automatically after the login finishes.</p>
<button onclick="openPopup()">Open popup</button>
<button onclick="finish(false)">Cancel</button>
<script>
const url = location.hash.substr(1)
let popupWindow
function finish(success) {
if (popupWindow) {
popupWindow.close()
}
fetch("", {method: success ? "POST" : "DELETE"}).then(() => window.close())
}
function openPopup() {
popupWindow = window.open(url)
}
window.addEventListener("message", evt => evt.data === "authDone" && finish(true))
</script>
</body>
</html>
`
func (c *Container) UIAFallback(loginType mautrix.AuthType, sessionID string) error {
errChan := make(chan error, 1)
server := &http.Server{Addr: ":29325"}
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(uiaFallbackPage))
} else if r.Method == "POST" || r.Method == "DELETE" {
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil {
debug.Printf("Failed to shut down SSO server: %v\n", err)
}
if r.Method == "DELETE" {
errChan <- errors.New("login cancelled")
} else {
errChan <- nil
}
}()
} else {
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
go server.ListenAndServe()
defer server.Close()
authURL := c.client.BuildURLWithQuery(mautrix.URLPath{"auth", loginType, "fallback", "web"}, map[string]string{
"session": sessionID,
})
link := url.URL{
Scheme: "http",
Host: "localhost:29325",
Path: "/",
Fragment: authURL,
}
err := open.Open(link.String())
if err != nil {
return err
}
err = <-errChan
return err
}

157
pkg/gomuks/buffer.go Normal file
View file

@ -0,0 +1,157 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"encoding/json"
"fmt"
"maps"
"slices"
"sync"
"github.com/coder/websocket"
"go.mau.fi/gomuks/pkg/hicli"
)
type WebsocketCloseFunc func(websocket.StatusCode, string)
type EventBuffer struct {
lock sync.RWMutex
buf []*hicli.JSONCommand
minID int64
maxID int64
MaxSize int
websocketClosers map[uint64]WebsocketCloseFunc
lastAckedID map[uint64]int64
eventListeners map[uint64]func(*hicli.JSONCommand)
nextListenerID uint64
}
func NewEventBuffer(maxSize int) *EventBuffer {
return &EventBuffer{
websocketClosers: make(map[uint64]WebsocketCloseFunc),
lastAckedID: make(map[uint64]int64),
eventListeners: make(map[uint64]func(*hicli.JSONCommand)),
buf: make([]*hicli.JSONCommand, 0, 32),
MaxSize: maxSize,
minID: -1,
}
}
func (eb *EventBuffer) Push(evt any) {
data, err := json.Marshal(evt)
if err != nil {
panic(fmt.Errorf("failed to marshal event %T: %w", evt, err))
}
allowCache := true
if syncComplete, ok := evt.(*hicli.SyncComplete); ok && syncComplete.Since != nil && *syncComplete.Since == "" {
// Don't cache initial sync responses
allowCache = false
} else if _, ok := evt.(*hicli.Typing); ok {
// Also don't cache typing events
allowCache = false
}
eb.lock.Lock()
defer eb.lock.Unlock()
jc := &hicli.JSONCommand{
Command: hicli.EventTypeName(evt),
Data: data,
}
if allowCache {
eb.addToBuffer(jc)
}
for _, listener := range eb.eventListeners {
listener(jc)
}
}
func (eb *EventBuffer) GetClosers() []WebsocketCloseFunc {
eb.lock.Lock()
defer eb.lock.Unlock()
return slices.Collect(maps.Values(eb.websocketClosers))
}
func (eb *EventBuffer) Unsubscribe(listenerID uint64) {
eb.lock.Lock()
defer eb.lock.Unlock()
delete(eb.eventListeners, listenerID)
delete(eb.websocketClosers, listenerID)
}
func (eb *EventBuffer) addToBuffer(evt *hicli.JSONCommand) {
eb.maxID--
evt.RequestID = eb.maxID
if len(eb.lastAckedID) > 0 {
eb.buf = append(eb.buf, evt)
} else {
eb.minID = eb.maxID - 1
}
if len(eb.buf) > eb.MaxSize {
eb.buf = eb.buf[len(eb.buf)-eb.MaxSize:]
eb.minID = eb.buf[0].RequestID
}
}
func (eb *EventBuffer) ClearListenerLastAckedID(listenerID uint64) {
eb.lock.Lock()
defer eb.lock.Unlock()
delete(eb.lastAckedID, listenerID)
eb.gc()
}
func (eb *EventBuffer) SetLastAckedID(listenerID uint64, ackedID int64) {
eb.lock.Lock()
defer eb.lock.Unlock()
eb.lastAckedID[listenerID] = ackedID
eb.gc()
}
func (eb *EventBuffer) gc() {
neededMinID := eb.maxID
for lid, evtID := range eb.lastAckedID {
if evtID > eb.minID {
delete(eb.lastAckedID, lid)
} else if evtID > neededMinID {
neededMinID = evtID
}
}
if neededMinID < eb.minID {
eb.buf = eb.buf[eb.minID-neededMinID:]
eb.minID = neededMinID
}
}
func (eb *EventBuffer) Subscribe(resumeFrom int64, closeForRestart WebsocketCloseFunc, cb func(*hicli.JSONCommand)) (uint64, []*hicli.JSONCommand) {
eb.lock.Lock()
defer eb.lock.Unlock()
eb.nextListenerID++
id := eb.nextListenerID
eb.eventListeners[id] = cb
if closeForRestart != nil {
eb.websocketClosers[id] = closeForRestart
}
var resumeData []*hicli.JSONCommand
if resumeFrom < eb.minID {
resumeData = eb.buf[eb.minID-resumeFrom+1:]
eb.lastAckedID[id] = resumeFrom
} else {
eb.lastAckedID[id] = eb.maxID
}
return id, resumeData
}

165
pkg/gomuks/config.go Normal file
View file

@ -0,0 +1,165 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/chzyer/readline"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
"go.mau.fi/zeroconfig"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
type Config struct {
Web WebConfig `yaml:"web"`
Matrix MatrixConfig `yaml:"matrix"`
Push PushConfig `yaml:"push"`
Media MediaConfig `yaml:"media"`
Logging zeroconfig.Config `yaml:"logging"`
}
type MatrixConfig struct {
DisableHTTP2 bool `yaml:"disable_http2"`
}
type PushConfig struct {
FCMGateway string `yaml:"fcm_gateway"`
}
type MediaConfig struct {
ThumbnailSize int `yaml:"thumbnail_size"`
}
type WebConfig struct {
ListenAddress string `yaml:"listen_address"`
Username string `yaml:"username"`
PasswordHash string `yaml:"password_hash"`
TokenKey string `yaml:"token_key"`
DebugEndpoints bool `yaml:"debug_endpoints"`
EventBufferSize int `yaml:"event_buffer_size"`
OriginPatterns []string `yaml:"origin_patterns"`
}
var defaultFileWriter = zeroconfig.WriterConfig{
Type: zeroconfig.WriterTypeFile,
Format: "json",
FileConfig: zeroconfig.FileConfig{
Filename: "",
MaxSize: 100 * 1024 * 1024,
MaxBackups: 10,
},
}
func makeDefaultConfig() Config {
return Config{
Web: WebConfig{
ListenAddress: "localhost:29325",
},
Matrix: MatrixConfig{
DisableHTTP2: false,
},
Media: MediaConfig{
ThumbnailSize: 120,
},
Logging: zeroconfig.Config{
MinLevel: ptr.Ptr(zerolog.DebugLevel),
Writers: []zeroconfig.WriterConfig{{
Type: zeroconfig.WriterTypeStdout,
Format: zeroconfig.LogFormatPrettyColored,
}, defaultFileWriter},
},
}
}
func (gmx *Gomuks) LoadConfig() error {
file, err := os.Open(filepath.Join(gmx.ConfigDir, "config.yaml"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
gmx.Config = makeDefaultConfig()
changed := false
if file != nil {
err = yaml.NewDecoder(file).Decode(&gmx.Config)
if err != nil {
return err
}
} else {
changed = true
}
if gmx.Config.Web.TokenKey == "" {
gmx.Config.Web.TokenKey = random.String(64)
changed = true
}
if !gmx.DisableAuth && (gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "") {
fmt.Println("Please create a username and password for authenticating the web app")
gmx.Config.Web.Username, err = readline.Line("Username: ")
if err != nil {
return fmt.Errorf("failed to read username: %w", err)
} else if len(gmx.Config.Web.Username) == 0 || len(gmx.Config.Web.Username) > 32 {
return fmt.Errorf("username must be 1-32 characters long")
}
passwd, err := readline.Password("Password: ")
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
hash, err := bcrypt.GenerateFromPassword(passwd, 12)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
gmx.Config.Web.PasswordHash = string(hash)
changed = true
}
if gmx.Config.Web.EventBufferSize <= 0 {
gmx.Config.Web.EventBufferSize = 512
changed = true
}
if gmx.Config.Push.FCMGateway == "" {
gmx.Config.Push.FCMGateway = "https://push.gomuks.app"
changed = true
}
if gmx.Config.Media.ThumbnailSize == 0 {
gmx.Config.Media.ThumbnailSize = 120
changed = true
}
if len(gmx.Config.Web.OriginPatterns) == 0 {
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
changed = true
}
if changed {
err = gmx.SaveConfig()
if err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
}
gmx.EventBuffer = NewEventBuffer(gmx.Config.Web.EventBufferSize)
return nil
}
func (gmx *Gomuks) SaveConfig() error {
file, err := os.OpenFile(filepath.Join(gmx.ConfigDir, "config.yaml"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
return yaml.NewEncoder(file).Encode(&gmx.Config)
}

258
pkg/gomuks/gomuks.go Normal file
View file

@ -0,0 +1,258 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"embed"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"sync"
"syscall"
"time"
"github.com/coder/websocket"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr"
"golang.org/x/net/http2"
"go.mau.fi/gomuks/pkg/hicli"
)
type Gomuks struct {
Log *zerolog.Logger
Server *http.Server
Client *hicli.HiClient
Version string
Commit string
LinkifiedVersion string
BuildTime time.Time
ConfigDir string
DataDir string
CacheDir string
TempDir string
LogDir string
FrontendFS embed.FS
indexWithETag []byte
frontendETag string
Config Config
DisableAuth bool
stopOnce sync.Once
stopChan chan struct{}
EventBuffer *EventBuffer
}
func NewGomuks() *Gomuks {
return &Gomuks{
stopChan: make(chan struct{}),
}
}
func (gmx *Gomuks) InitDirectories() {
// We need 4 directories: config, data, cache, logs
//
// 1. If GOMUKS_ROOT is set, all directories are created under that.
// 2. If GOMUKS_*_HOME is set, that value is used as the directory.
// 3. Use system-specific defaults as below
//
// *nix:
// - Config: $XDG_CONFIG_HOME/gomuks or $HOME/.config/gomuks
// - Data: $XDG_DATA_HOME/gomuks or $HOME/.local/share/gomuks
// - Cache: $XDG_CACHE_HOME/gomuks or $HOME/.cache/gomuks
// - Logs: $XDG_STATE_HOME/gomuks or $HOME/.local/state/gomuks
//
// Windows:
// - Config and Data: %AppData%\gomuks
// - Cache: %LocalAppData%\gomuks
// - Logs: %LocalAppData%\gomuks\logs
//
// macOS:
// - Config and Data: $HOME/Library/Application Support/gomuks
// - Cache: $HOME/Library/Caches/gomuks
// - Logs: $HOME/Library/Logs/gomuks
if gomuksRoot := os.Getenv("GOMUKS_ROOT"); gomuksRoot != "" {
exerrors.PanicIfNotNil(os.MkdirAll(gomuksRoot, 0700))
gmx.CacheDir = filepath.Join(gomuksRoot, "cache")
gmx.ConfigDir = filepath.Join(gomuksRoot, "config")
gmx.DataDir = filepath.Join(gomuksRoot, "data")
gmx.LogDir = filepath.Join(gomuksRoot, "logs")
} else {
homeDir := exerrors.Must(os.UserHomeDir())
if cacheDir := os.Getenv("GOMUKS_CACHE_HOME"); cacheDir != "" {
gmx.CacheDir = cacheDir
} else {
gmx.CacheDir = filepath.Join(exerrors.Must(os.UserCacheDir()), "gomuks")
}
if configDir := os.Getenv("GOMUKS_CONFIG_HOME"); configDir != "" {
gmx.ConfigDir = configDir
} else {
gmx.ConfigDir = filepath.Join(exerrors.Must(os.UserConfigDir()), "gomuks")
}
if dataDir := os.Getenv("GOMUKS_DATA_HOME"); dataDir != "" {
gmx.DataDir = dataDir
} else if dataDir = os.Getenv("XDG_DATA_HOME"); dataDir != "" {
gmx.DataDir = filepath.Join(dataDir, "gomuks")
} else if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
gmx.DataDir = gmx.ConfigDir
} else {
gmx.DataDir = filepath.Join(homeDir, ".local", "share", "gomuks")
}
if logDir := os.Getenv("GOMUKS_LOGS_HOME"); logDir != "" {
gmx.LogDir = logDir
} else if logDir = os.Getenv("XDG_STATE_HOME"); logDir != "" {
gmx.LogDir = filepath.Join(logDir, "gomuks")
} else if runtime.GOOS == "darwin" {
gmx.LogDir = filepath.Join(homeDir, "Library", "Logs", "gomuks")
} else if runtime.GOOS == "windows" {
gmx.LogDir = filepath.Join(gmx.CacheDir, "logs")
} else {
gmx.LogDir = filepath.Join(homeDir, ".local", "state", "gomuks")
}
}
if gmx.TempDir = os.Getenv("GOMUKS_TMPDIR"); gmx.TempDir == "" {
gmx.TempDir = filepath.Join(gmx.CacheDir, "tmp")
}
exerrors.PanicIfNotNil(os.MkdirAll(gmx.ConfigDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.CacheDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.TempDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.DataDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.LogDir, 0700))
defaultFileWriter.FileConfig.Filename = filepath.Join(gmx.LogDir, "gomuks.log")
}
func (gmx *Gomuks) SetupLog() {
gmx.Log = exerrors.Must(gmx.Config.Logging.Compile())
exzerolog.SetupDefaults(gmx.Log)
}
func (gmx *Gomuks) StartClient() {
hicli.HTMLSanitizerImgSrcTemplate = "_gomuks/media/%s/%s?encrypted=false"
rawDB, err := dbutil.NewFromConfig("gomuks", dbutil.Config{
PoolConfig: dbutil.PoolConfig{
Type: "sqlite3-fk-wal",
URI: fmt.Sprintf("file:%s/gomuks.db?_txlock=immediate", gmx.DataDir),
MaxOpenConns: 5,
MaxIdleConns: 1,
},
}, dbutil.ZeroLogger(gmx.Log.With().Str("component", "hicli").Str("db_section", "main").Logger()))
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to open database")
os.Exit(10)
}
ctx := gmx.Log.WithContext(context.Background())
gmx.Client = hicli.New(
rawDB,
nil,
gmx.Log.With().Str("component", "hicli").Logger(),
[]byte("meow"),
gmx.HandleEvent,
)
gmx.Client.LogoutFunc = gmx.Logout
httpClient := gmx.Client.Client.Client
httpClient.Transport.(*http.Transport).ForceAttemptHTTP2 = false
if !gmx.Config.Matrix.DisableHTTP2 {
h2, err := http2.ConfigureTransports(httpClient.Transport.(*http.Transport))
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to configure HTTP/2")
os.Exit(13)
}
h2.ReadIdleTimeout = 30 * time.Second
}
userID, err := gmx.Client.DB.Account.GetFirstUserID(ctx)
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get first user ID")
os.Exit(11)
}
err = gmx.Client.Start(ctx, userID, nil)
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to start client")
os.Exit(12)
}
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started")
}
func (gmx *Gomuks) HandleEvent(evt any) {
gmx.EventBuffer.Push(evt)
syncComplete, ok := evt.(*hicli.SyncComplete)
if ok && ptr.Val(syncComplete.Since) != "" {
go gmx.SendPushNotifications(syncComplete)
}
}
func (gmx *Gomuks) Stop() {
gmx.stopOnce.Do(func() {
close(gmx.stopChan)
})
}
func (gmx *Gomuks) WaitForInterrupt() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
select {
case <-c:
case <-gmx.stopChan:
}
}
func (gmx *Gomuks) DirectStop() {
for _, closer := range gmx.EventBuffer.GetClosers() {
closer(websocket.StatusServiceRestart, "Server shutting down")
}
gmx.Client.Stop()
if gmx.Server != nil {
err := gmx.Server.Close()
if err != nil {
gmx.Log.Error().Err(err).Msg("Failed to close server")
}
}
}
func (gmx *Gomuks) Run() {
gmx.InitDirectories()
err := gmx.LoadConfig()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to load config:", err)
os.Exit(9)
}
gmx.SetupLog()
gmx.Log.Info().
Str("version", gmx.Version).
Str("go_version", runtime.Version()).
Time("built_at", gmx.BuildTime).
Msg("Initializing gomuks")
gmx.StartServer()
gmx.StartClient()
gmx.Log.Info().Msg("Initialization complete")
gmx.WaitForInterrupt()
gmx.Log.Info().Msg("Shutting down...")
gmx.DirectStop()
gmx.Log.Info().Msg("Shutdown complete")
os.Exit(0)
}

110
pkg/gomuks/keys.go Normal file
View file

@ -0,0 +1,110 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"strconv"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exhttp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/id"
)
func (gmx *Gomuks) ExportKeys(w http.ResponseWriter, r *http.Request) {
found, correct := gmx.doBasicAuth(r)
if !found || !correct {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for key export request")
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Cache-Control", "no-store")
err := r.ParseForm()
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to parse form")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Failed to parse form data\n"))
return
}
roomID := id.RoomID(r.PathValue("room_id"))
var sessions dbutil.RowIter[*crypto.InboundGroupSession]
filename := "gomuks-keys.txt"
if roomID == "" {
sessions = gmx.Client.CryptoStore.GetAllGroupSessions(r.Context())
} else {
filename = fmt.Sprintf("gomuks-keys-%s.txt", roomID)
sessions = gmx.Client.CryptoStore.GetGroupSessionsForRoom(r.Context(), roomID)
}
export, err := crypto.ExportKeysIter(r.FormValue("passphrase"), sessions)
if errors.Is(err, crypto.ErrNoSessionsForExport) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("No keys found\n"))
return
} else if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to export keys")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Failed to export keys (see logs for more details)\n"))
return
}
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
w.Header().Set("Content-Length", strconv.Itoa(len(export)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(export)
}
var badMultipartForm = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.BAD_FORM_DATA", Err: "Failed to parse form data", StatusCode: http.StatusBadRequest}
func (gmx *Gomuks) ImportKeys(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(5 * 1024 * 1024)
if err != nil {
badMultipartForm.Write(w)
return
}
export, _, err := r.FormFile("export")
if err != nil {
badMultipartForm.WithMessage("Failed to get export file from form: %w", err).Write(w)
return
}
exportData, err := io.ReadAll(export)
if err != nil {
badMultipartForm.WithMessage("Failed to read export file: %w", err).Write(w)
return
}
importedCount, totalCount, err := gmx.Client.Crypto.ImportKeys(r.Context(), r.FormValue("passphrase"), exportData)
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to import keys")
mautrix.MUnknown.WithMessage("Failed to import keys: %w", err).Write(w)
return
}
hlog.FromRequest(r).Info().
Int("imported_count", importedCount).
Int("total_count", totalCount).
Msg("Successfully imported keys")
exhttp.WriteJSONResponse(w, http.StatusOK, map[string]int{
"imported": importedCount,
"total": totalCount,
})
}

64
pkg/gomuks/logout.go Normal file
View file

@ -0,0 +1,64 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"errors"
"os"
"path/filepath"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
)
func (gmx *Gomuks) Logout(ctx context.Context) error {
log := zerolog.Ctx(ctx)
log.Info().Msg("Stopping client and logging out")
gmx.Client.Stop()
_, err := gmx.Client.Client.Logout(ctx)
if err != nil && !errors.Is(err, mautrix.MUnknownToken) {
log.Warn().Err(err).Msg("Failed to log out")
return err
}
log.Info().Msg("Logout complete, removing data")
err = os.RemoveAll(gmx.CacheDir)
if err != nil {
log.Err(err).Str("cache_dir", gmx.CacheDir).Msg("Failed to remove cache dir")
}
if gmx.DataDir == gmx.ConfigDir {
err = os.Remove(filepath.Join(gmx.DataDir, "gomuks.db"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
log.Err(err).Str("data_dir", gmx.DataDir).Msg("Failed to remove database")
}
_ = os.Remove(filepath.Join(gmx.DataDir, "gomuks.db-shm"))
_ = os.Remove(filepath.Join(gmx.DataDir, "gomuks.db-wal"))
} else {
err = os.RemoveAll(gmx.DataDir)
if err != nil {
log.Err(err).Str("data_dir", gmx.DataDir).Msg("Failed to remove data dir")
}
}
log.Info().Msg("Re-initializing directories")
gmx.InitDirectories()
log.Info().Msg("Restarting client")
gmx.StartClient()
gmx.Client.EventHandler(gmx.Client.State())
gmx.Client.EventHandler(gmx.Client.SyncStatus.Load())
log.Info().Msg("Client restarted")
return nil
}

728
pkg/gomuks/media.go Normal file
View file

@ -0,0 +1,728 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"html"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/buckket/go-blurhash"
"github.com/disintegration/imaging"
"github.com/gabriel-vasile/mimetype"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/exhttp"
"go.mau.fi/util/ffmpeg"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
cwebp "go.mau.fi/webp"
_ "golang.org/x/image/webp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli/database"
)
var ErrBadGateway = mautrix.RespError{
ErrCode: "FI.MAU.GOMUKS.BAD_GATEWAY",
StatusCode: http.StatusBadGateway,
}
func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, r *http.Request, entry *database.Media, force, useThumbnail bool) bool {
if !entry.UseCache() {
if force {
mautrix.MNotFound.WithMessage("Media not found in cache").Write(w)
return true
}
return false
}
etag := entry.ETag(useThumbnail)
if entry.Error != nil {
w.Header().Set("Mau-Cached-Error", "true")
entry.Error.Write(w)
return true
} else if etag != "" && r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return true
} else if entry.MimeType != "" && r.URL.Query().Has("fallback") && !isAllowedAvatarMime(entry.MimeType) {
w.WriteHeader(http.StatusUnsupportedMediaType)
return true
}
log := zerolog.Ctx(ctx)
hash := entry.Hash
if useThumbnail {
if entry.ThumbnailError != "" {
log.Debug().Str(zerolog.ErrorFieldName, entry.ThumbnailError).Msg("Returning cached thumbnail error")
w.WriteHeader(http.StatusInternalServerError)
return true
}
if entry.ThumbnailHash == nil {
err := gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize)
if errors.Is(err, os.ErrNotExist) && !force {
return false
} else if err != nil {
log.Err(err).Msg("Failed to generate avatar thumbnail")
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err)
w.WriteHeader(http.StatusInternalServerError)
return true
} else {
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil)
}
}
hash = entry.ThumbnailHash
}
cacheFile, err := os.Open(gmx.cacheEntryToPath(hash[:]))
if useThumbnail && errors.Is(err, os.ErrNotExist) {
err = gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize)
if errors.Is(err, os.ErrNotExist) && !force {
return false
} else if err != nil {
log.Err(err).Msg("Failed to generate avatar thumbnail")
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err)
w.WriteHeader(http.StatusInternalServerError)
return true
} else {
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil)
cacheFile, err = os.Open(gmx.cacheEntryToPath(hash[:]))
}
}
if err != nil {
if errors.Is(err, os.ErrNotExist) && !force {
return false
}
log.Err(err).Msg("Failed to open cache file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to open cache file: %v", err)).Write(w)
return true
}
defer func() {
_ = cacheFile.Close()
}()
cacheEntryToHeaders(w, entry, useThumbnail)
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, cacheFile)
if err != nil {
log.Err(err).Msg("Failed to copy cache file to response")
}
return true
}
func (gmx *Gomuks) cacheEntryToPath(hash []byte) string {
hashPath := hex.EncodeToString(hash[:])
return filepath.Join(gmx.CacheDir, "media", hashPath[0:2], hashPath[2:4], hashPath[4:])
}
func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media, thumbnail bool) {
if thumbnail {
w.Header().Set("Content-Type", "image/webp")
w.Header().Set("Content-Length", strconv.FormatInt(entry.ThumbnailSize, 10))
w.Header().Set("Content-Disposition", "inline; filename=thumbnail.webp")
} else {
w.Header().Set("Content-Type", entry.MimeType)
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10))
w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName}))
}
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';")
w.Header().Set("Cache-Control", "max-age=2592000, immutable")
w.Header().Set("ETag", entry.ETag(thumbnail))
}
func (gmx *Gomuks) saveMediaCacheEntryWithThumbnail(ctx context.Context, entry *database.Media, err error) {
if errors.Is(err, os.ErrNotExist) {
return
}
if err != nil {
entry.ThumbnailError = err.Error()
}
err = gmx.Client.DB.Media.Put(ctx, entry)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save cache entry after generating thumbnail")
}
}
func (gmx *Gomuks) generateAvatarThumbnail(entry *database.Media, size int) error {
cacheFile, err := os.Open(gmx.cacheEntryToPath(entry.Hash[:]))
if err != nil {
return fmt.Errorf("failed to open full file: %w", err)
}
img, _, err := image.Decode(cacheFile)
if err != nil {
return fmt.Errorf("failed to decode image: %w", err)
}
tempFile, err := os.CreateTemp(gmx.TempDir, "thumbnail-*")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
}()
thumbnailImage := imaging.Thumbnail(img, size, size, imaging.Lanczos)
fileHasher := sha256.New()
wrappedWriter := io.MultiWriter(fileHasher, tempFile)
err = cwebp.Encode(wrappedWriter, thumbnailImage, &cwebp.Options{Quality: 80})
if err != nil {
return fmt.Errorf("failed to encode thumbnail: %w", err)
}
fileInfo, err := tempFile.Stat()
if err != nil {
return fmt.Errorf("failed to stat temporary file: %w", err)
}
entry.ThumbnailHash = (*[32]byte)(fileHasher.Sum(nil))
entry.ThumbnailError = ""
entry.ThumbnailSize = fileInfo.Size()
cachePath := gmx.cacheEntryToPath(entry.ThumbnailHash[:])
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
err = os.Rename(tempFile.Name(), cachePath)
if err != nil {
return fmt.Errorf("failed to rename temporary file: %w", err)
}
return nil
}
type noErrorWriter struct {
io.Writer
}
func (new *noErrorWriter) Write(p []byte) (n int, err error) {
n, _ = new.Writer.Write(p)
return
}
// note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts
const fallbackAvatarTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<rect x="0" y="0" width="1000" height="1000" fill="%s"/>
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
>%s</text>
</svg>`
type avatarResponseWriter struct {
http.ResponseWriter
bgColor string
character string
errored bool
}
func isAllowedAvatarMime(mime string) bool {
switch mime {
case "image/png", "image/jpeg", "image/gif", "image/webp":
return true
default:
return false
}
}
func (w *avatarResponseWriter) WriteHeader(statusCode int) {
if statusCode != http.StatusOK && statusCode != http.StatusNotModified {
data := []byte(fmt.Sprintf(fallbackAvatarTemplate, w.bgColor, html.EscapeString(w.character)))
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Del("Content-Disposition")
w.ResponseWriter.WriteHeader(http.StatusOK)
_, _ = w.ResponseWriter.Write(data)
w.errored = true
return
}
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *avatarResponseWriter) Write(p []byte) (n int, err error) {
if w.errored {
return len(p), nil
}
return w.ResponseWriter.Write(p)
}
func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
mxc := id.ContentURI{
Homeserver: r.PathValue("server"),
FileID: r.PathValue("media_id"),
}
if !mxc.IsValid() {
mautrix.MInvalidParam.WithMessage("Invalid mxc URI").Write(w)
return
}
query := r.URL.Query()
fallback := query.Get("fallback")
if fallback != "" {
fallbackParts := strings.Split(fallback, ":")
if len(fallbackParts) == 2 {
w = &avatarResponseWriter{
ResponseWriter: w,
bgColor: fallbackParts[0],
character: fallbackParts[1],
}
}
}
encrypted, _ := strconv.ParseBool(query.Get("encrypted"))
useThumbnail := query.Get("thumbnail") == "avatar"
logVal := zerolog.Ctx(r.Context()).With().
Stringer("mxc_uri", mxc).
Bool("encrypted", encrypted).
Logger()
log := &logVal
ctx := log.WithContext(r.Context())
cacheEntry, err := gmx.Client.DB.Media.Get(ctx, mxc)
if err != nil {
log.Err(err).Msg("Failed to get cached media entry")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to get cached media entry: %v", err)).Write(w)
return
} else if (cacheEntry == nil || cacheEntry.EncFile == nil) && encrypted {
mautrix.MNotFound.WithMessage("Media encryption keys not found in cache").Write(w)
return
} else if cacheEntry != nil && cacheEntry.EncFile != nil && !encrypted {
mautrix.MNotFound.WithMessage("Tried to download encrypted media without encrypted flag").Write(w)
return
}
if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false, useThumbnail) {
return
}
tempFile, err := os.CreateTemp(gmx.TempDir, "download-*")
if err != nil {
log.Err(err).Msg("Failed to create temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create temp file: %v", err)).Write(w)
return
}
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
}()
resp, err := gmx.Client.Client.Download(ctx, mxc)
if err != nil {
if ctx.Err() != nil {
w.WriteHeader(499)
return
}
log.Err(err).Msg("Failed to download media")
var httpErr mautrix.HTTPError
if cacheEntry == nil {
cacheEntry = &database.Media{
MXC: mxc,
}
}
if cacheEntry.Error == nil {
cacheEntry.Error = &database.MediaError{
ReceivedAt: jsontime.UnixMilliNow(),
Attempts: 1,
}
} else {
cacheEntry.Error.Attempts++
cacheEntry.Error.ReceivedAt = jsontime.UnixMilliNow()
}
if errors.As(err, &httpErr) {
if httpErr.WrappedError != nil {
cacheEntry.Error.Matrix = ptr.Ptr(ErrBadGateway.WithMessage(httpErr.WrappedError.Error()))
cacheEntry.Error.StatusCode = http.StatusBadGateway
} else if httpErr.RespError != nil {
cacheEntry.Error.Matrix = httpErr.RespError
cacheEntry.Error.StatusCode = httpErr.Response.StatusCode
} else {
cacheEntry.Error.Matrix = ptr.Ptr(mautrix.MUnknown.WithMessage("Server returned non-JSON error with status %d", httpErr.Response.StatusCode))
cacheEntry.Error.StatusCode = httpErr.Response.StatusCode
}
} else {
cacheEntry.Error.Matrix = ptr.Ptr(ErrBadGateway.WithMessage(err.Error()))
cacheEntry.Error.StatusCode = http.StatusBadGateway
}
err = gmx.Client.DB.Media.Put(ctx, cacheEntry)
if err != nil {
log.Err(err).Msg("Failed to save errored cache entry")
}
cacheEntry.Error.Write(w)
return
}
defer func() {
_ = resp.Body.Close()
}()
if cacheEntry == nil {
cacheEntry = &database.Media{
MXC: mxc,
MimeType: resp.Header.Get("Content-Type"),
Size: resp.ContentLength,
}
}
reader := resp.Body
if cacheEntry.EncFile != nil {
err = cacheEntry.EncFile.PrepareForDecryption()
if err != nil {
log.Err(err).Msg("Failed to prepare media for decryption")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to prepare media for decryption: %v", err)).Write(w)
return
}
reader = cacheEntry.EncFile.DecryptStream(reader)
}
if cacheEntry.FileName == "" {
_, params, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
cacheEntry.FileName = params["filename"]
}
if cacheEntry.MimeType == "" {
cacheEntry.MimeType = resp.Header.Get("Content-Type")
}
cacheEntry.Size = resp.ContentLength
fileHasher := sha256.New()
wrappedReader := io.TeeReader(reader, fileHasher)
if cacheEntry.Size > 0 && cacheEntry.EncFile == nil && !useThumbnail {
cacheEntryToHeaders(w, cacheEntry, useThumbnail)
w.WriteHeader(http.StatusOK)
wrappedReader = io.TeeReader(wrappedReader, &noErrorWriter{w})
w = nil
}
cacheEntry.Size, err = io.Copy(tempFile, wrappedReader)
if err != nil {
log.Err(err).Msg("Failed to copy media to temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to copy media to temp file: %v", err)).Write(w)
return
}
err = reader.Close()
if err != nil {
log.Err(err).Msg("Failed to close media reader")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to finish reading media: %v", err)).Write(w)
return
}
_ = tempFile.Close()
cacheEntry.Hash = (*[32]byte)(fileHasher.Sum(nil))
cacheEntry.Error = nil
err = gmx.Client.DB.Media.Put(ctx, cacheEntry)
if err != nil {
log.Err(err).Msg("Failed to save cache entry")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to save cache entry: %v", err)).Write(w)
return
}
cachePath := gmx.cacheEntryToPath(cacheEntry.Hash[:])
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
log.Err(err).Msg("Failed to create cache directory")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create cache directory: %v", err)).Write(w)
return
}
err = os.Rename(tempFile.Name(), cachePath)
if err != nil {
log.Err(err).Msg("Failed to rename temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to rename temp file: %v", err)).Write(w)
return
}
if w != nil {
gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, true, useThumbnail)
}
}
func (gmx *Gomuks) UploadMedia(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r)
tempFile, err := os.CreateTemp(gmx.TempDir, "upload-*")
if err != nil {
log.Err(err).Msg("Failed to create temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create temp file: %v", err)).Write(w)
return
}
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
}()
hasher := sha256.New()
_, err = io.Copy(tempFile, io.TeeReader(r.Body, hasher))
if err != nil {
log.Err(err).Msg("Failed to copy upload media to temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to copy media to temp file: %v", err)).Write(w)
return
}
_ = tempFile.Close()
checksum := hasher.Sum(nil)
cachePath := gmx.cacheEntryToPath(checksum)
if _, err = os.Stat(cachePath); err == nil {
log.Debug().Str("path", cachePath).Msg("Media already exists in cache, removing temp file")
} else {
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
log.Err(err).Msg("Failed to create cache directory")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create cache directory: %v", err)).Write(w)
return
}
err = os.Rename(tempFile.Name(), cachePath)
if err != nil {
log.Err(err).Msg("Failed to rename temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to rename temp file: %v", err)).Write(w)
return
}
}
cacheFile, err := os.Open(cachePath)
if err != nil {
log.Err(err).Msg("Failed to open cache file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to open cache file: %v", err)).Write(w)
return
}
msgType, info, defaultFileName, err := gmx.generateFileInfo(r.Context(), cacheFile)
if err != nil {
log.Err(err).Msg("Failed to generate file info")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to generate file info: %v", err)).Write(w)
return
}
encrypt, _ := strconv.ParseBool(r.URL.Query().Get("encrypt"))
if msgType == event.MsgVideo {
err = gmx.generateVideoThumbnail(r.Context(), cacheFile.Name(), encrypt, info)
if err != nil {
log.Warn().Err(err).Msg("Failed to generate video thumbnail")
}
}
fileName := r.URL.Query().Get("filename")
if fileName == "" {
fileName = defaultFileName
}
content := &event.MessageEventContent{
MsgType: msgType,
Body: fileName,
Info: info,
FileName: fileName,
}
content.File, content.URL, err = gmx.uploadFile(r.Context(), checksum, cacheFile, encrypt, int64(info.Size), info.MimeType, fileName)
if err != nil {
log.Err(err).Msg("Failed to upload media")
writeMaybeRespError(err, w)
return
}
exhttp.WriteJSONResponse(w, http.StatusOK, content)
}
func (gmx *Gomuks) uploadFile(ctx context.Context, checksum []byte, cacheFile *os.File, encrypt bool, fileSize int64, mimeType, fileName string) (*event.EncryptedFileInfo, id.ContentURIString, error) {
cm := &database.Media{
FileName: fileName,
MimeType: mimeType,
Size: fileSize,
Hash: (*[32]byte)(checksum),
}
var cacheReader io.ReadSeekCloser = cacheFile
if encrypt {
cm.EncFile = attachment.NewEncryptedFile()
cacheReader = cm.EncFile.EncryptStream(cacheReader)
mimeType = "application/octet-stream"
fileName = ""
}
resp, err := gmx.Client.Client.UploadMedia(ctx, mautrix.ReqUploadMedia{
Content: cacheReader,
ContentLength: fileSize,
ContentType: mimeType,
FileName: fileName,
})
err2 := cacheReader.Close()
if err != nil {
return nil, "", err
} else if err2 != nil {
return nil, "", fmt.Errorf("failed to close cache reader: %w", err)
}
cm.MXC = resp.ContentURI
err = gmx.Client.DB.Media.Put(ctx, cm)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("mxc", cm.MXC).
Hex("checksum", checksum).
Msg("Failed to save cache entry")
}
if cm.EncFile != nil {
return &event.EncryptedFileInfo{
EncryptedFile: *cm.EncFile,
URL: resp.ContentURI.CUString(),
}, "", nil
} else {
return nil, resp.ContentURI.CUString(), nil
}
}
func (gmx *Gomuks) generateFileInfo(ctx context.Context, file *os.File) (event.MessageType, *event.FileInfo, string, error) {
fileInfo, err := file.Stat()
if err != nil {
return "", nil, "", fmt.Errorf("failed to stat cache file: %w", err)
}
mimeType, err := mimetype.DetectReader(file)
if err != nil {
return "", nil, "", fmt.Errorf("failed to detect mime type: %w", err)
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return "", nil, "", fmt.Errorf("failed to seek to start of file: %w", err)
}
info := &event.FileInfo{
MimeType: mimeType.String(),
Size: int(fileInfo.Size()),
}
var msgType event.MessageType
var defaultFileName string
switch strings.Split(mimeType.String(), "/")[0] {
case "image":
msgType = event.MsgImage
defaultFileName = "image" + mimeType.Extension()
img, _, err := image.Decode(file)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to decode image config")
} else {
bounds := img.Bounds()
info.Width = bounds.Dx()
info.Height = bounds.Dy()
hash, err := blurhash.Encode(4, 3, img)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to generate image blurhash")
}
info.AnoaBlurhash = hash
}
case "video":
msgType = event.MsgVideo
defaultFileName = "video" + mimeType.Extension()
case "audio":
msgType = event.MsgAudio
defaultFileName = "audio" + mimeType.Extension()
default:
msgType = event.MsgFile
defaultFileName = "file" + mimeType.Extension()
}
if msgType == event.MsgVideo || msgType == event.MsgAudio {
probe, err := ffmpeg.Probe(ctx, file.Name())
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to probe video")
} else if probe != nil && probe.Format != nil {
info.Duration = int(probe.Format.Duration * 1000)
for _, stream := range probe.Streams {
if stream.Width != 0 {
info.Width = stream.Width
info.Height = stream.Height
break
}
}
}
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return "", nil, "", fmt.Errorf("failed to seek to start of file: %w", err)
}
return msgType, info, defaultFileName, nil
}
func (gmx *Gomuks) generateVideoThumbnail(ctx context.Context, filePath string, encrypt bool, saveInto *event.FileInfo) error {
tempPath := filepath.Join(gmx.TempDir, "thumbnail-"+random.String(12)+".jpeg")
defer os.Remove(tempPath)
err := ffmpeg.ConvertPathWithDestination(
ctx, filePath, tempPath, nil,
[]string{"-frames:v", "1", "-update", "1", "-f", "image2"},
false,
)
if err != nil {
return err
}
tempFile, err := os.Open(tempPath)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer tempFile.Close()
fileInfo, err := tempFile.Stat()
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
hasher := sha256.New()
_, err = io.Copy(hasher, tempFile)
if err != nil {
return fmt.Errorf("failed to hash file: %w", err)
}
thumbnailInfo := &event.FileInfo{
MimeType: "image/jpeg",
Size: int(fileInfo.Size()),
}
_, err = tempFile.Seek(0, io.SeekStart)
if err != nil {
return fmt.Errorf("failed to seek to start of file: %w", err)
}
img, _, err := image.Decode(tempFile)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to decode thumbnail image config")
} else {
bounds := img.Bounds()
thumbnailInfo.Width = bounds.Dx()
thumbnailInfo.Height = bounds.Dy()
hash, err := blurhash.Encode(4, 3, img)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to generate image blurhash")
}
thumbnailInfo.AnoaBlurhash = hash
}
_ = tempFile.Close()
checksum := hasher.Sum(nil)
cachePath := gmx.cacheEntryToPath(checksum)
if _, err = os.Stat(cachePath); err == nil {
zerolog.Ctx(ctx).Debug().Str("path", cachePath).Msg("Media already exists in cache, removing temp file")
} else {
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
err = os.Rename(tempPath, cachePath)
if err != nil {
return fmt.Errorf("failed to rename file: %w", err)
}
}
tempFile, err = os.Open(cachePath)
if err != nil {
return fmt.Errorf("failed to open renamed file: %w", err)
}
saveInto.ThumbnailFile, saveInto.ThumbnailURL, err = gmx.uploadFile(ctx, checksum, tempFile, encrypt, fileInfo.Size(), "image/jpeg", "thumbnail.jpeg")
if err != nil {
return fmt.Errorf("failed to upload: %w", err)
}
saveInto.ThumbnailInfo = thumbnailInfo
return nil
}
func writeMaybeRespError(err error, w http.ResponseWriter) {
var httpErr mautrix.HTTPError
if errors.As(err, &httpErr) {
if httpErr.WrappedError != nil {
ErrBadGateway.WithMessage(httpErr.WrappedError.Error()).Write(w)
} else if httpErr.RespError != nil {
httpErr.RespError.Write(w)
} else {
mautrix.MUnknown.WithMessage("Server returned non-JSON error").Write(w)
}
} else {
mautrix.MUnknown.WithMessage(err.Error()).Write(w)
}
}

258
pkg/gomuks/push.go Normal file
View file

@ -0,0 +1,258 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/pkg/hicli/database"
)
type PushNotification struct {
Dismiss []PushDismiss `json:"dismiss,omitempty"`
OrigMessages []*PushNewMessage `json:"-"`
RawMessages []json.RawMessage `json:"messages,omitempty"`
ImageAuth string `json:"image_auth,omitempty"`
ImageAuthExpiry *jsontime.UnixMilli `json:"image_auth_expiry,omitempty"`
HasImportant bool `json:"-"`
}
type PushDismiss struct {
RoomID id.RoomID `json:"room_id"`
}
var pushClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
ResponseHeaderTimeout: 10 * time.Second,
Proxy: http.ProxyFromEnvironment,
ForceAttemptHTTP2: true,
MaxIdleConns: 5,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
Timeout: 60 * time.Second,
}
func (gmx *Gomuks) SendPushNotifications(sync *hicli.SyncComplete) {
var ctx context.Context
var push PushNotification
for _, room := range sync.Rooms {
if room.DismissNotifications && len(push.Dismiss) < 10 {
push.Dismiss = append(push.Dismiss, PushDismiss{RoomID: room.Meta.ID})
}
for _, notif := range room.Notifications {
if ctx == nil {
ctx = gmx.Log.With().
Str("action", "send push notification").
Logger().WithContext(context.Background())
}
msg := gmx.formatPushNotificationMessage(ctx, notif)
if msg == nil {
continue
}
msgJSON, err := json.Marshal(msg)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Int64("event_rowid", int64(notif.RowID)).
Stringer("event_id", notif.Event.ID).
Msg("Failed to marshal push notification")
continue
} else if len(msgJSON) > 1500 {
// This should not happen as long as formatPushNotificationMessage doesn't return too long messages
zerolog.Ctx(ctx).Error().
Int64("event_rowid", int64(notif.RowID)).
Stringer("event_id", notif.Event.ID).
Msg("Push notification too long")
continue
}
push.RawMessages = append(push.RawMessages, msgJSON)
push.OrigMessages = append(push.OrigMessages, msg)
}
}
if len(push.Dismiss) == 0 && len(push.RawMessages) == 0 {
return
}
if ctx == nil {
ctx = gmx.Log.With().
Str("action", "send push notification").
Logger().WithContext(context.Background())
}
pushRegs, err := gmx.Client.DB.PushRegistration.GetAll(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get push registrations")
return
}
if len(push.RawMessages) > 0 {
exp := time.Now().Add(24 * time.Hour)
push.ImageAuth = gmx.generateImageToken(24 * time.Hour)
push.ImageAuthExpiry = ptr.Ptr(jsontime.UM(exp))
}
for notif := range push.Split {
gmx.SendPushNotification(ctx, pushRegs, notif)
}
}
func (pn *PushNotification) Split(yield func(*PushNotification) bool) {
const maxSize = 2000
currentSize := 0
offset := 0
hasSound := false
for i, msg := range pn.RawMessages {
if len(msg) >= maxSize {
// This is already checked in SendPushNotifications, so this should never happen
panic("push notification message too long")
}
if currentSize+len(msg) > maxSize {
yield(&PushNotification{
Dismiss: pn.Dismiss,
RawMessages: pn.RawMessages[offset:i],
ImageAuth: pn.ImageAuth,
HasImportant: hasSound,
})
offset = i
currentSize = 0
hasSound = false
}
currentSize += len(msg)
hasSound = hasSound || pn.OrigMessages[i].Sound
}
yield(&PushNotification{
Dismiss: pn.Dismiss,
RawMessages: pn.RawMessages[offset:],
ImageAuth: pn.ImageAuth,
HasImportant: hasSound,
})
}
func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*database.PushRegistration, notif *PushNotification) {
log := zerolog.Ctx(ctx).With().
Bool("important", notif.HasImportant).
Int("message_count", len(notif.RawMessages)).
Int("dismiss_count", len(notif.Dismiss)).
Logger()
ctx = log.WithContext(ctx)
rawPayload, err := json.Marshal(notif)
if err != nil {
log.Err(err).Msg("Failed to marshal push notification")
return
} else if base64.StdEncoding.EncodedLen(len(rawPayload)) >= 4000 {
log.Error().Msg("Generated push payload too long")
return
}
for _, reg := range pushRegs {
devicePayload := rawPayload
encrypted := false
if reg.Encryption.Key != nil {
var err error
devicePayload, err = encryptPush(rawPayload, reg.Encryption.Key)
if err != nil {
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to encrypt push payload")
continue
}
encrypted = true
}
switch reg.Type {
case database.PushTypeFCM:
if !encrypted {
log.Warn().
Str("device_id", reg.DeviceID).
Msg("FCM push registration doesn't have encryption key")
continue
}
var token string
err = json.Unmarshal(reg.Data, &token)
if err != nil {
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to unmarshal FCM token")
continue
}
gmx.SendFCMPush(ctx, token, devicePayload, notif.HasImportant)
}
}
}
func encryptPush(payload, key []byte) ([]byte, error) {
if len(key) != 32 {
return nil, fmt.Errorf("encryption key must be 32 bytes long")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM cipher: %w", err)
}
iv := random.Bytes(12)
encrypted := make([]byte, 12, 12+len(payload))
copy(encrypted, iv)
return gcm.Seal(encrypted, iv, payload, nil), nil
}
type PushRequest struct {
Token string `json:"token"`
Payload []byte `json:"payload"`
HighPriority bool `json:"high_priority"`
}
func (gmx *Gomuks) SendFCMPush(ctx context.Context, token string, payload []byte, highPriority bool) {
wrappedPayload, _ := json.Marshal(&PushRequest{
Token: token,
Payload: payload,
HighPriority: highPriority,
})
url := fmt.Sprintf("%s/_gomuks/push/fcm", gmx.Config.Push.FCMGateway)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wrappedPayload))
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to create push request")
return
}
resp, err := pushClient.Do(req)
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("push_token", token).Msg("Failed to send push request")
} else if resp.StatusCode != http.StatusOK {
zerolog.Ctx(ctx).Error().
Int("status", resp.StatusCode).
Str("push_token", token).
Msg("Non-200 status while sending push request")
} else {
zerolog.Ctx(ctx).Trace().
Int("status", resp.StatusCode).
Str("push_token", token).
Msg("Sent push request")
}
if resp != nil {
_ = resp.Body.Close()
}
}

171
pkg/gomuks/pushmessage.go Normal file
View file

@ -0,0 +1,171 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"encoding/json"
"fmt"
"net/url"
"unicode/utf8"
"github.com/rs/zerolog"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/pkg/hicli/database"
)
type PushNewMessage struct {
Timestamp jsontime.UnixMilli `json:"timestamp"`
EventID id.EventID `json:"event_id"`
EventRowID database.EventRowID `json:"event_rowid"`
RoomID id.RoomID `json:"room_id"`
RoomName string `json:"room_name"`
RoomAvatar string `json:"room_avatar,omitempty"`
Sender NotificationUser `json:"sender"`
Self NotificationUser `json:"self"`
Text string `json:"text"`
Image string `json:"image,omitempty"`
Mention bool `json:"mention,omitempty"`
Reply bool `json:"reply,omitempty"`
Sound bool `json:"sound,omitempty"`
}
type NotificationUser struct {
ID id.UserID `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
}
func getAvatarLinkForNotification(name, ident string, uri id.ContentURIString) string {
parsed := uri.ParseOrIgnore()
if !parsed.IsValid() {
return ""
}
var fallbackChar rune
if name == "" {
fallbackChar, _ = utf8.DecodeRuneInString(ident[1:])
} else {
fallbackChar, _ = utf8.DecodeRuneInString(name)
}
return fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false&fallback=%s", parsed.Homeserver, parsed.FileID, url.QueryEscape(string(fallbackChar)))
}
func (gmx *Gomuks) getNotificationUser(ctx context.Context, roomID id.RoomID, userID id.UserID) (user NotificationUser) {
user = NotificationUser{ID: userID, Name: userID.Localpart()}
memberEvt, err := gmx.Client.DB.CurrentState.Get(ctx, roomID, event.StateMember, userID.String())
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("of_user_id", userID).Msg("Failed to get member event")
return
} else if memberEvt == nil {
return
}
var memberContent event.MemberEventContent
_ = json.Unmarshal(memberEvt.Content, &memberContent)
if memberContent.Displayname != "" {
user.Name = memberContent.Displayname
}
if len(user.Name) > 50 {
user.Name = user.Name[:50] + "…"
}
if memberContent.AvatarURL != "" {
user.Avatar = getAvatarLinkForNotification(memberContent.Displayname, userID.String(), memberContent.AvatarURL)
}
return
}
func (gmx *Gomuks) formatPushNotificationMessage(ctx context.Context, notif hicli.SyncNotification) *PushNewMessage {
evtType := notif.Event.Type
rawContent := notif.Event.Content
if evtType == event.EventEncrypted.Type {
evtType = notif.Event.DecryptedType
rawContent = notif.Event.Decrypted
}
if evtType != event.EventMessage.Type && evtType != event.EventSticker.Type {
return nil
}
var content event.MessageEventContent
err := json.Unmarshal(rawContent, &content)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Stringer("event_id", notif.Event.ID).
Msg("Failed to unmarshal message content to format push notification")
return nil
}
var roomAvatar, image string
if notif.Room.Avatar != nil {
avatarIdent := notif.Room.ID.String()
if ptr.Val(notif.Room.DMUserID) != "" {
avatarIdent = notif.Room.DMUserID.String()
}
roomAvatar = getAvatarLinkForNotification(ptr.Val(notif.Room.Name), avatarIdent, notif.Room.Avatar.CUString())
}
roomName := ptr.Val(notif.Room.Name)
if roomName == "" {
roomName = "Unnamed room"
}
if len(roomName) > 50 {
roomName = roomName[:50] + "…"
}
text := content.Body
if len(text) > 400 {
text = text[:350] + "[…]"
}
if content.MsgType == event.MsgImage || evtType == event.EventSticker.Type {
if content.File != nil && content.File.URL != "" {
parsed := content.File.URL.ParseOrIgnore()
if len(content.File.URL) < 255 && parsed.IsValid() {
image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=true", parsed.Homeserver, parsed.FileID)
}
} else if content.URL != "" {
parsed := content.URL.ParseOrIgnore()
if len(content.URL) < 255 && parsed.IsValid() {
image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false", parsed.Homeserver, parsed.FileID)
}
}
if content.FileName == "" || content.FileName == content.Body {
text = "Sent a photo"
}
} else if content.MsgType.IsMedia() {
if content.FileName == "" || content.FileName == content.Body {
text = "Sent a file: " + text
}
}
return &PushNewMessage{
Timestamp: notif.Event.Timestamp,
EventID: notif.Event.ID,
EventRowID: notif.Event.RowID,
RoomID: notif.Room.ID,
RoomName: roomName,
RoomAvatar: roomAvatar,
Sender: gmx.getNotificationUser(ctx, notif.Room.ID, notif.Event.Sender),
Self: gmx.getNotificationUser(ctx, notif.Room.ID, gmx.Client.Account.UserID),
Text: text,
Image: image,
Mention: content.Mentions.Has(gmx.Client.Account.UserID),
Reply: content.RelatesTo.GetNonFallbackReplyTo() != "",
Sound: notif.Sound,
}
}

321
pkg/gomuks/server.go Normal file
View file

@ -0,0 +1,321 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"io/fs"
"net/http"
_ "net/http/pprof"
"strconv"
"strings"
"time"
"github.com/alecthomas/chroma/v2/styles"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exhttp"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/requestlog"
"golang.org/x/crypto/bcrypt"
"maunium.net/go/mautrix"
"go.mau.fi/gomuks/pkg/hicli"
)
func (gmx *Gomuks) CreateAPIRouter() http.Handler {
api := http.NewServeMux()
api.HandleFunc("GET /websocket", gmx.HandleWebsocket)
api.HandleFunc("POST /auth", gmx.Authenticate)
api.HandleFunc("POST /upload", gmx.UploadMedia)
api.HandleFunc("GET /sso", gmx.HandleSSOComplete)
api.HandleFunc("POST /sso", gmx.PrepareSSO)
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
api.HandleFunc("POST /keys/export", gmx.ExportKeys)
api.HandleFunc("POST /keys/export/{room_id}", gmx.ExportKeys)
api.HandleFunc("POST /keys/import", gmx.ImportKeys)
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
return exhttp.ApplyMiddleware(
api,
hlog.NewHandler(*gmx.Log),
hlog.RequestIDHandler("request_id", "Request-ID"),
requestlog.AccessLogger(false),
)
}
func (gmx *Gomuks) StartServer() {
api := gmx.CreateAPIRouter()
router := http.NewServeMux()
if gmx.Config.Web.DebugEndpoints {
router.Handle("/debug/", http.DefaultServeMux)
}
router.Handle("/_gomuks/", exhttp.ApplyMiddleware(
api,
exhttp.StripPrefix("/_gomuks"),
gmx.AuthMiddleware,
))
if frontend, err := fs.Sub(gmx.FrontendFS, "dist"); err != nil {
gmx.Log.Warn().Msg("Frontend not found")
} else {
router.Handle("/", gmx.FrontendCacheMiddleware(http.FileServerFS(frontend)))
if gmx.Commit != "unknown" && !gmx.BuildTime.IsZero() {
gmx.frontendETag = fmt.Sprintf(`"%s-%s"`, gmx.Commit, gmx.BuildTime.Format(time.RFC3339))
indexFile, err := frontend.Open("index.html")
if err != nil {
gmx.Log.Err(err).Msg("Failed to open index.html")
} else {
data, err := io.ReadAll(indexFile)
_ = indexFile.Close()
if err == nil {
gmx.indexWithETag = bytes.Replace(
data,
[]byte("<!-- etag placeholder -->"),
[]byte(fmt.Sprintf(`<meta name="gomuks-frontend-etag" content="%s">`, html.EscapeString(gmx.frontendETag))),
1,
)
}
}
}
}
gmx.Server = &http.Server{
Addr: gmx.Config.Web.ListenAddress,
Handler: router,
}
go func() {
err := gmx.Server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}()
gmx.Log.Info().Str("address", gmx.Config.Web.ListenAddress).Msg("Server started")
}
func (gmx *Gomuks) FrontendCacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if gmx.frontendETag != "" && r.Header.Get("If-None-Match") == gmx.frontendETag {
w.WriteHeader(http.StatusNotModified)
return
}
if strings.HasPrefix(r.URL.Path, "/assets/") {
w.Header().Set("Cache-Control", "max-age=604800, immutable")
}
if gmx.frontendETag != "" {
w.Header().Set("ETag", gmx.frontendETag)
if r.URL.Path == "/" && gmx.indexWithETag != nil {
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Content-Length", strconv.Itoa(len(gmx.indexWithETag)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(gmx.indexWithETag)
return
}
}
next.ServeHTTP(w, r)
})
}
var (
ErrInvalidHeader = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.INVALID_HEADER", StatusCode: http.StatusForbidden}
ErrMissingCookie = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.MISSING_COOKIE", Err: "Missing gomuks_auth cookie", StatusCode: http.StatusUnauthorized}
ErrInvalidCookie = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.INVALID_COOKIE", Err: "Invalid gomuks_auth cookie", StatusCode: http.StatusUnauthorized}
)
type tokenData struct {
Username string `json:"username"`
Expiry jsontime.Unix `json:"expiry"`
ImageOnly bool `json:"image_only,omitempty"`
}
func (gmx *Gomuks) validateToken(token string, output any) bool {
if len(token) > 4096 {
return false
}
parts := strings.Split(token, ".")
if len(parts) != 2 {
return false
}
rawJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return false
}
checksum, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return false
}
hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey))
hasher.Write(rawJSON)
if !hmac.Equal(hasher.Sum(nil), checksum) {
return false
}
err = json.Unmarshal(rawJSON, output)
return err == nil
}
func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool {
if len(token) > 500 {
return false
}
var td tokenData
return gmx.validateToken(token, &td) &&
td.Username == gmx.Config.Web.Username &&
td.Expiry.After(time.Now()) &&
td.ImageOnly == imageOnly
}
func (gmx *Gomuks) generateToken() (string, time.Time) {
expiry := time.Now().Add(7 * 24 * time.Hour)
return gmx.signToken(tokenData{
Username: gmx.Config.Web.Username,
Expiry: jsontime.U(expiry),
}), expiry
}
func (gmx *Gomuks) generateImageToken(expiry time.Duration) string {
return gmx.signToken(tokenData{
Username: gmx.Config.Web.Username,
Expiry: jsontime.U(time.Now().Add(expiry)),
ImageOnly: true,
})
}
func (gmx *Gomuks) signToken(td any) string {
data := exerrors.Must(json.Marshal(td))
hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey))
hasher.Write(data)
checksum := hasher.Sum(nil)
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum)
}
func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter, created, jsonOutput bool) {
token, expiry := gmx.generateToken()
if !jsonOutput {
http.SetCookie(w, &http.Cookie{
Name: "gomuks_auth",
Value: token,
Expires: expiry,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
if created {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusOK)
}
if jsonOutput {
_ = json.NewEncoder(w).Encode(map[string]string{"token": token})
}
}
func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
if gmx.DisableAuth {
w.WriteHeader(http.StatusOK)
return
}
jsonOutput := r.URL.Query().Get("output") == "json"
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
authCookie, err := r.Cookie("gomuks_auth")
if err == nil && gmx.validateAuth(authCookie.Value, false) {
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
gmx.writeTokenCookie(w, false, jsonOutput)
} else if found, correct := gmx.doBasicAuth(r); found && correct {
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
gmx.writeTokenCookie(w, true, jsonOutput)
} else {
if !found {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
} else {
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
}
if allowPrompt {
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
}
w.WriteHeader(http.StatusUnauthorized)
}
}
func (gmx *Gomuks) doBasicAuth(r *http.Request) (found, correct bool) {
var username, password string
username, password, found = r.BasicAuth()
if !found {
return
}
usernameHash := sha256.Sum256([]byte(username))
expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username))
usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:])
passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
correct = passwordCorrect && usernameCorrect
return
}
func isImageFetch(header http.Header) bool {
return header.Get("Sec-Fetch-Site") == "cross-site" &&
header.Get("Sec-Fetch-Mode") == "no-cors" &&
header.Get("Sec-Fetch-Dest") == "image"
}
func (gmx *Gomuks) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/media") &&
isImageFetch(r.Header) &&
gmx.validateAuth(r.URL.Query().Get("image_auth"), true) &&
r.URL.Query().Get("encrypted") == "false" {
next.ServeHTTP(w, r)
return
}
if r.URL.Path != "/auth" {
authCookie, err := r.Cookie("gomuks_auth")
if err != nil {
ErrMissingCookie.Write(w)
return
} else if !gmx.validateAuth(authCookie.Value, false) {
http.SetCookie(w, &http.Cookie{
Name: "gomuks_auth",
MaxAge: -1,
})
ErrInvalidCookie.Write(w)
return
}
}
next.ServeHTTP(w, r)
})
}
func (gmx *Gomuks) GetCodeblockCSS(w http.ResponseWriter, r *http.Request) {
styleName := r.PathValue("style")
if !strings.HasSuffix(styleName, ".css") {
w.WriteHeader(http.StatusNotFound)
return
}
style := styles.Get(strings.TrimSuffix(styleName, ".css"))
if style == nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/css")
_ = hicli.CodeBlockFormatter.WriteCSS(w, style)
}

128
pkg/gomuks/sso.go Normal file
View file

@ -0,0 +1,128 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"time"
"go.mau.fi/util/random"
"maunium.net/go/mautrix"
)
const ssoErrorPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>gomuks web</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<h1>Failed to log in</h1>
<p><code>%s</code></p>
</body>
</html>`
func (gmx *Gomuks) parseSSOServerURL(r *http.Request) error {
cookie, _ := r.Cookie("gomuks_sso_session")
if cookie == nil {
return fmt.Errorf("no SSO session cookie")
}
var cookieData SSOCookieData
if !gmx.validateToken(cookie.Value, &cookieData) {
return fmt.Errorf("invalid SSO session cookie")
} else if cookieData.SessionID != r.URL.Query().Get("gomuksSession") {
return fmt.Errorf("session ID mismatch in query param and cookie")
} else if time.Until(cookieData.Expiry) < 0 {
return fmt.Errorf("SSO session cookie expired")
}
var err error
gmx.Client.Client.HomeserverURL, err = url.Parse(cookieData.HomeserverURL)
if err != nil {
return fmt.Errorf("failed to parse server URL: %w", err)
}
return nil
}
func (gmx *Gomuks) HandleSSOComplete(w http.ResponseWriter, r *http.Request) {
err := gmx.parseSSOServerURL(r)
if err != nil {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, ssoErrorPage, html.EscapeString(err.Error()))
return
}
err = gmx.Client.Login(r.Context(), &mautrix.ReqLogin{
Type: mautrix.AuthTypeToken,
Token: r.URL.Query().Get("loginToken"),
})
if err != nil {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, ssoErrorPage, html.EscapeString(err.Error()))
} else {
w.Header().Set("Location", "..")
w.WriteHeader(http.StatusFound)
}
}
type SSOCookieData struct {
SessionID string `json:"session_id"`
HomeserverURL string `json:"homeserver_url"`
Expiry time.Time `json:"expiry"`
}
func (gmx *Gomuks) PrepareSSO(w http.ResponseWriter, r *http.Request) {
var data SSOCookieData
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
mautrix.MBadJSON.WithMessage("Failed to decode request JSON").Write(w)
return
}
data.SessionID = random.String(16)
data.Expiry = time.Now().Add(30 * time.Minute)
cookieData, err := json.Marshal(&data)
if err != nil {
mautrix.MUnknown.WithMessage("Failed to encode cookie data").Write(w)
return
}
http.SetCookie(w, &http.Cookie{
Name: "gomuks_sso_session",
Value: gmx.signToken(json.RawMessage(cookieData)),
Expires: data.Expiry,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(cookieData)
}

338
pkg/gomuks/websocket.go Normal file
View file

@ -0,0 +1,338 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"encoding/json"
"errors"
"net/http"
"runtime/debug"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
"github.com/rs/zerolog"
"go.mau.fi/util/exerrors"
"go.mau.fi/gomuks/pkg/hicli"
)
func writeCmd[T any](ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommandCustom[T]) error {
writer, err := conn.Writer(ctx, websocket.MessageText)
if err != nil {
return err
}
err = json.NewEncoder(writer).Encode(&cmd)
if err != nil {
return err
}
return writer.Close()
}
const (
StatusEventsStuck = 4001
StatusPingTimeout = 4002
)
var emptyObject = json.RawMessage("{}")
type PingRequestData struct {
LastReceivedID int64 `json:"last_received_id"`
}
var runID = time.Now().UnixNano()
type RunData struct {
RunID string `json:"run_id"`
ETag string `json:"etag"`
}
func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
var conn *websocket.Conn
log := zerolog.Ctx(r.Context())
recoverPanic := func(context string) bool {
err := recover()
if err != nil {
logEvt := log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Str("goroutine", context)
if realErr, ok := err.(error); ok {
logEvt = logEvt.Err(realErr)
} else {
logEvt = logEvt.Any(zerolog.ErrorFieldName, err)
}
logEvt.Msg("Panic in websocket handler")
return true
}
return false
}
defer recoverPanic("read loop")
conn, acceptErr := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: gmx.Config.Web.OriginPatterns,
})
if acceptErr != nil {
log.Warn().Err(acceptErr).Msg("Failed to accept websocket connection")
return
}
resumeFrom, _ := strconv.ParseInt(r.URL.Query().Get("last_received_event"), 10, 64)
resumeRunID, _ := strconv.ParseInt(r.URL.Query().Get("run_id"), 10, 64)
log.Info().
Int64("resume_from", resumeFrom).
Int64("resume_run_id", resumeRunID).
Int64("current_run_id", runID).
Msg("Accepted new websocket connection")
conn.SetReadLimit(128 * 1024)
ctx, cancel := context.WithCancel(context.Background())
ctx = log.WithContext(ctx)
var listenerID uint64
evts := make(chan *hicli.JSONCommand, 32)
forceClose := func() {
cancel()
if listenerID != 0 {
gmx.EventBuffer.Unsubscribe(listenerID)
}
_ = conn.CloseNow()
close(evts)
}
var closeOnce sync.Once
defer closeOnce.Do(forceClose)
closeManually := func(statusCode websocket.StatusCode, reason string) {
log.Debug().Stringer("status_code", statusCode).Str("reason", reason).Msg("Closing connection manually")
_ = conn.Close(statusCode, reason)
closeOnce.Do(forceClose)
}
if resumeRunID != runID {
resumeFrom = 0
}
var resumeData []*hicli.JSONCommand
listenerID, resumeData = gmx.EventBuffer.Subscribe(resumeFrom, closeManually, func(evt *hicli.JSONCommand) {
if ctx.Err() != nil {
return
}
select {
case evts <- evt:
default:
log.Warn().Msg("Event queue full, closing connection")
cancel()
go func() {
defer recoverPanic("closing connection after error in event handler")
_ = conn.Close(StatusEventsStuck, "Event queue full")
closeOnce.Do(forceClose)
}()
}
})
didResume := resumeData != nil
lastDataReceived := &atomic.Int64{}
lastDataReceived.Store(time.Now().UnixMilli())
const RecvTimeout = 60 * time.Second
lastImageAuthTokenSent := time.Now()
sendImageAuthToken := func() {
err := writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "image_auth_token",
Data: exerrors.Must(json.Marshal(gmx.generateImageToken(1 * time.Hour))),
})
if err != nil {
log.Err(err).Msg("Failed to write image auth token message")
return
}
}
go func() {
defer recoverPanic("event loop")
defer closeOnce.Do(forceClose)
for _, cmd := range resumeData {
err := writeCmd(ctx, conn, cmd)
if err != nil {
log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write outgoing event from resume data")
return
} else {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent outgoing event from resume data")
}
}
resumeData = nil
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
ctxDone := ctx.Done()
for {
select {
case cmd := <-evts:
err := writeCmd(ctx, conn, cmd)
if err != nil {
log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write outgoing event")
return
} else {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent outgoing event")
}
case <-ticker.C:
if time.Since(lastImageAuthTokenSent) > 30*time.Minute {
sendImageAuthToken()
lastImageAuthTokenSent = time.Now()
}
if time.Now().UnixMilli()-lastDataReceived.Load() > RecvTimeout.Milliseconds() {
log.Warn().Msg("No data received in a minute, closing connection")
_ = conn.Close(StatusPingTimeout, "Ping timeout")
return
}
case <-ctxDone:
return
}
}
}()
submitCmd := func(cmd *hicli.JSONCommand) {
defer func() {
if recoverPanic("command handler") {
_ = conn.Close(websocket.StatusInternalError, "Command handler panicked")
closeOnce.Do(forceClose)
}
}()
if cmd.Data == nil {
cmd.Data = emptyObject
}
log.Trace().
Int64("req_id", cmd.RequestID).
Str("command", cmd.Command).
RawJSON("data", cmd.Data).
Msg("Received command")
var resp *hicli.JSONCommand
if cmd.Command == "ping" {
resp = &hicli.JSONCommand{
Command: "pong",
RequestID: cmd.RequestID,
}
var pingData PingRequestData
err := json.Unmarshal(cmd.Data, &pingData)
if err != nil {
log.Err(err).Msg("Failed to parse ping data")
} else if pingData.LastReceivedID != 0 {
gmx.EventBuffer.SetLastAckedID(listenerID, pingData.LastReceivedID)
}
} else {
resp = gmx.Client.SubmitJSONCommand(ctx, cmd)
}
if ctx.Err() != nil {
return
}
err := writeCmd(ctx, conn, resp)
if err != nil && ctx.Err() == nil {
log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write response")
closeOnce.Do(forceClose)
} else {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command")
}
}
initErr := writeCmd(ctx, conn, &hicli.JSONCommandCustom[*RunData]{
Command: "run_id",
Data: &RunData{
RunID: strconv.FormatInt(runID, 10),
ETag: gmx.frontendETag,
},
})
if initErr != nil {
log.Err(initErr).Msg("Failed to write init client state message")
return
}
initErr = writeCmd(ctx, conn, &hicli.JSONCommandCustom[*hicli.ClientState]{
Command: "client_state",
Data: gmx.Client.State(),
})
if initErr != nil {
log.Err(initErr).Msg("Failed to write init client state message")
return
}
initErr = writeCmd(ctx, conn, &hicli.JSONCommandCustom[*hicli.SyncStatus]{
Command: "sync_status",
Data: gmx.Client.SyncStatus.Load(),
})
if initErr != nil {
log.Err(initErr).Msg("Failed to write init sync status message")
return
}
go sendImageAuthToken()
if gmx.Client.IsLoggedIn() && !didResume {
go gmx.sendInitialData(ctx, conn)
}
log.Debug().Bool("did_resume", didResume).Msg("Connection initialization complete")
var closeErr websocket.CloseError
for {
msgType, reader, err := conn.Reader(ctx)
if err != nil {
if errors.As(err, &closeErr) {
log.Debug().
Stringer("status_code", closeErr.Code).
Str("reason", closeErr.Reason).
Msg("Connection closed")
if closeErr.Code == websocket.StatusGoingAway {
gmx.EventBuffer.ClearListenerLastAckedID(listenerID)
}
} else {
log.Err(err).Msg("Failed to read message")
}
return
} else if msgType != websocket.MessageText {
log.Error().Stringer("message_type", msgType).Msg("Unexpected message type")
_ = conn.Close(websocket.StatusUnsupportedData, "Non-text message")
return
}
lastDataReceived.Store(time.Now().UnixMilli())
var cmd hicli.JSONCommand
err = json.NewDecoder(reader).Decode(&cmd)
if err != nil {
log.Err(err).Msg("Failed to parse message")
_ = conn.Close(websocket.StatusUnsupportedData, "Invalid JSON")
return
}
go submitCmd(&cmd)
}
}
func (gmx *Gomuks) sendInitialData(ctx context.Context, conn *websocket.Conn) {
log := zerolog.Ctx(ctx)
var roomCount int
for payload := range gmx.Client.GetInitialSync(ctx, 100) {
roomCount += len(payload.Rooms)
marshaledPayload, err := json.Marshal(&payload)
if err != nil {
log.Err(err).Msg("Failed to marshal initial rooms to send to client")
return
}
err = writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "sync_complete",
RequestID: 0,
Data: marshaledPayload,
})
if err != nil {
log.Err(err).Msg("Failed to send initial rooms to client")
return
}
}
if ctx.Err() != nil {
return
}
err := writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "init_complete",
RequestID: 0,
})
if err != nil {
log.Err(err).Msg("Failed to send initial rooms done event to client")
return
}
log.Info().Int("room_count", roomCount).Msg("Sent initial rooms to client")
}

113
pkg/hicli/backupupload.go Normal file
View file

@ -0,0 +1,113 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package hicli
import (
"context"
"encoding/json"
"fmt"
"slices"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/backup"
"maunium.net/go/mautrix/id"
)
func (c *HiClient) uploadKeysToBackup(ctx context.Context) {
log := zerolog.Ctx(ctx)
version := c.KeyBackupVersion
key := c.KeyBackupKey
if version == "" || key == nil {
return
}
sessions, err := c.CryptoStore.GetGroupSessionsWithoutKeyBackupVersion(ctx, version).AsList()
if err != nil {
log.Err(err).Msg("Failed to get megolm sessions that aren't backed up")
return
} else if len(sessions) == 0 {
return
}
log.Debug().Int("session_count", len(sessions)).Msg("Backing up megolm sessions")
for chunk := range slices.Chunk(sessions, 100) {
err = c.uploadKeyBackupBatch(ctx, version, key, chunk)
if err != nil {
log.Err(err).Msg("Failed to upload key backup batch")
return
}
err = c.CryptoStore.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
for _, sess := range chunk {
sess.KeyBackupVersion = version
err := c.CryptoStore.PutGroupSession(ctx, sess)
if err != nil {
return err
}
}
return nil
})
if err != nil {
log.Err(err).Msg("Failed to update key backup version of uploaded megolm sessions in database")
return
}
}
log.Info().Int("session_count", len(sessions)).Msg("Successfully uploaded megolm sessions to key backup")
}
func (c *HiClient) uploadKeyBackupBatch(ctx context.Context, version id.KeyBackupVersion, megolmBackupKey *backup.MegolmBackupKey, sessions []*crypto.InboundGroupSession) error {
if len(sessions) == 0 {
return nil
}
req := mautrix.ReqKeyBackup{
Rooms: map[id.RoomID]mautrix.ReqRoomKeyBackup{},
}
for _, session := range sessions {
sessionKey, err := session.Internal.Export(session.Internal.FirstKnownIndex())
if err != nil {
return fmt.Errorf("failed to export session data: %w", err)
}
sessionData, err := backup.EncryptSessionData(megolmBackupKey, &backup.MegolmSessionData{
Algorithm: id.AlgorithmMegolmV1,
ForwardingKeyChain: session.ForwardingChains,
SenderClaimedKeys: backup.SenderClaimedKeys{
Ed25519: session.SigningKey,
},
SenderKey: session.SenderKey,
SessionKey: string(sessionKey),
})
if err != nil {
return fmt.Errorf("failed to encrypt session data: %w", err)
}
jsonSessionData, err := json.Marshal(sessionData)
if err != nil {
return fmt.Errorf("failed to marshal session data: %w", err)
}
roomData, ok := req.Rooms[session.RoomID]
if !ok {
roomData = mautrix.ReqRoomKeyBackup{
Sessions: map[id.SessionID]mautrix.ReqKeyBackupData{},
}
req.Rooms[session.RoomID] = roomData
}
roomData.Sessions[session.ID()] = mautrix.ReqKeyBackupData{
FirstMessageIndex: int(session.Internal.FirstKnownIndex()),
ForwardedCount: len(session.ForwardingChains),
IsVerified: session.Internal.IsVerified(),
SessionData: jsonSessionData,
}
}
_, err := c.Client.PutKeysInBackup(ctx, version, &req)
return err
}

64
pkg/hicli/cryptohelper.go Normal file
View file

@ -0,0 +1,64 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package hicli
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type hiCryptoHelper HiClient
var _ mautrix.CryptoHelper = (*hiCryptoHelper)(nil)
func (h *hiCryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtType event.Type, content any) (*event.EncryptedEventContent, error) {
roomMeta, err := h.DB.Room.Get(ctx, roomID)
if err != nil {
return nil, fmt.Errorf("failed to get room metadata: %w", err)
} else if roomMeta == nil {
return nil, fmt.Errorf("unknown room")
}
return (*HiClient)(h).Encrypt(ctx, roomMeta, evtType, content)
}
func (h *hiCryptoHelper) Decrypt(ctx context.Context, evt *event.Event) (*event.Event, error) {
return h.Crypto.DecryptMegolmEvent(ctx, evt)
}
func (h *hiCryptoHelper) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool {
return h.Crypto.WaitForSession(ctx, roomID, senderKey, sessionID, timeout)
}
func (h *hiCryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) {
err := h.Crypto.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{
userID: {deviceID},
h.Account.UserID: {"*"},
})
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("room_id", roomID).
Stringer("session_id", sessionID).
Stringer("user_id", userID).
Msg("Failed to send room key request")
} else {
zerolog.Ctx(ctx).Debug().
Stringer("room_id", roomID).
Stringer("session_id", sessionID).
Stringer("user_id", userID).
Msg("Sent room key request")
}
}
func (h *hiCryptoHelper) Init(ctx context.Context) error {
return nil
}

View file

@ -0,0 +1,73 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"database/sql"
"errors"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getAccountQuery = `SELECT user_id, device_id, access_token, homeserver_url, next_batch FROM account WHERE user_id = $1`
putNextBatchQuery = `UPDATE account SET next_batch = $1 WHERE user_id = $2`
upsertAccountQuery = `
INSERT INTO account (user_id, device_id, access_token, homeserver_url, next_batch)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id)
DO UPDATE SET device_id = excluded.device_id,
access_token = excluded.access_token,
homeserver_url = excluded.homeserver_url,
next_batch = excluded.next_batch
`
)
type AccountQuery struct {
*dbutil.QueryHelper[*Account]
}
func (aq *AccountQuery) GetFirstUserID(ctx context.Context) (userID id.UserID, err error) {
var exists bool
if exists, err = aq.GetDB().TableExists(ctx, "account"); err != nil || !exists {
return
}
err = aq.GetDB().QueryRow(ctx, `SELECT user_id FROM account LIMIT 1`).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
return
}
func (aq *AccountQuery) Get(ctx context.Context, userID id.UserID) (*Account, error) {
return aq.QueryOne(ctx, getAccountQuery, userID)
}
func (aq *AccountQuery) PutNextBatch(ctx context.Context, userID id.UserID, nextBatch string) error {
return aq.Exec(ctx, putNextBatchQuery, nextBatch, userID)
}
func (aq *AccountQuery) Put(ctx context.Context, account *Account) error {
return aq.Exec(ctx, upsertAccountQuery, account.sqlVariables()...)
}
type Account struct {
UserID id.UserID
DeviceID id.DeviceID
AccessToken string
HomeserverURL string
NextBatch string
}
func (a *Account) Scan(row dbutil.Scannable) (*Account, error) {
return dbutil.ValueOrErr(a, row.Scan(&a.UserID, &a.DeviceID, &a.AccessToken, &a.HomeserverURL, &a.NextBatch))
}
func (a *Account) sqlVariables() []any {
return []any{a.UserID, a.DeviceID, a.AccessToken, a.HomeserverURL, a.NextBatch}
}

View file

@ -0,0 +1,92 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"database/sql"
"encoding/json"
"unsafe"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
const (
upsertAccountDataQuery = `
INSERT INTO account_data (user_id, type, content) VALUES ($1, $2, $3)
ON CONFLICT (user_id, type) DO UPDATE SET content = excluded.content
`
upsertRoomAccountDataQuery = `
INSERT INTO room_account_data (user_id, room_id, type, content) VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, room_id, type) DO UPDATE SET content = excluded.content
`
getGlobalAccountDataQuery = `
SELECT user_id, '', type, content FROM account_data WHERE user_id = $1
`
getRoomAccountDataQuery = `
SELECT user_id, room_id, type, content FROM room_account_data WHERE user_id = $1 AND room_id = $2
`
)
type AccountDataQuery struct {
*dbutil.QueryHelper[*AccountData]
}
func unsafeJSONString(content json.RawMessage) *string {
if content == nil {
return nil
}
str := unsafe.String(unsafe.SliceData(content), len(content))
return &str
}
func (adq *AccountDataQuery) Put(ctx context.Context, userID id.UserID, eventType event.Type, content json.RawMessage) (*AccountData, error) {
ad := &AccountData{
UserID: userID,
Type: eventType.Type,
Content: content,
}
return ad, adq.Exec(ctx, upsertAccountDataQuery, userID, eventType.Type, unsafeJSONString(content))
}
func (adq *AccountDataQuery) PutRoom(ctx context.Context, userID id.UserID, roomID id.RoomID, eventType event.Type, content json.RawMessage) (*AccountData, error) {
ad := &AccountData{
UserID: userID,
RoomID: roomID,
Type: eventType.Type,
Content: content,
}
return ad, adq.Exec(ctx, upsertRoomAccountDataQuery, userID, roomID, eventType.Type, unsafeJSONString(content))
}
func (adq *AccountDataQuery) GetAllGlobal(ctx context.Context, userID id.UserID) ([]*AccountData, error) {
return adq.QueryMany(ctx, getGlobalAccountDataQuery, userID)
}
func (adq *AccountDataQuery) GetAllRoom(ctx context.Context, userID id.UserID, roomID id.RoomID) ([]*AccountData, error) {
return adq.QueryMany(ctx, getRoomAccountDataQuery, userID, roomID)
}
type AccountData struct {
UserID id.UserID `json:"user_id"`
RoomID id.RoomID `json:"room_id,omitempty"`
Type string `json:"type"`
Content json.RawMessage `json:"content"`
}
func (a *AccountData) Scan(row dbutil.Scannable) (*AccountData, error) {
var roomID sql.NullString
err := row.Scan(&a.UserID, &roomID, &a.Type, (*[]byte)(&a.Content))
if err != nil {
return nil, err
}
a.RoomID = id.RoomID(roomID.String)
return a, nil
}

View file

@ -0,0 +1,93 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/util/dbutil"
_ "go.mau.fi/util/dbutil/litestream"
"go.mau.fi/gomuks/pkg/hicli/database/upgrades"
)
type Database struct {
*dbutil.Database
Account *AccountQuery
AccountData *AccountDataQuery
Room *RoomQuery
InvitedRoom *InvitedRoomQuery
Event *EventQuery
CurrentState *CurrentStateQuery
Timeline *TimelineQuery
SessionRequest *SessionRequestQuery
Receipt *ReceiptQuery
Media *MediaQuery
SpaceEdge *SpaceEdgeQuery
PushRegistration *PushRegistrationQuery
}
func New(rawDB *dbutil.Database) *Database {
rawDB.UpgradeTable = upgrades.Table
eventQH := dbutil.MakeQueryHelper(rawDB, newEvent)
return &Database{
Database: rawDB,
Account: &AccountQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccount)},
AccountData: &AccountDataQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newAccountData)},
Room: &RoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newRoom)},
InvitedRoom: &InvitedRoomQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newInvitedRoom)},
Event: &EventQuery{QueryHelper: eventQH},
CurrentState: &CurrentStateQuery{QueryHelper: eventQH},
Timeline: &TimelineQuery{QueryHelper: eventQH},
SessionRequest: &SessionRequestQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSessionRequest)},
Receipt: &ReceiptQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newReceipt)},
Media: &MediaQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newMedia)},
SpaceEdge: &SpaceEdgeQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newSpaceEdge)},
PushRegistration: &PushRegistrationQuery{QueryHelper: dbutil.MakeQueryHelper(rawDB, newPushRegistration)},
}
}
func newSessionRequest(_ *dbutil.QueryHelper[*SessionRequest]) *SessionRequest {
return &SessionRequest{}
}
func newEvent(_ *dbutil.QueryHelper[*Event]) *Event {
return &Event{}
}
func newRoom(_ *dbutil.QueryHelper[*Room]) *Room {
return &Room{}
}
func newInvitedRoom(_ *dbutil.QueryHelper[*InvitedRoom]) *InvitedRoom {
return &InvitedRoom{}
}
func newReceipt(_ *dbutil.QueryHelper[*Receipt]) *Receipt {
return &Receipt{}
}
func newMedia(_ *dbutil.QueryHelper[*Media]) *Media {
return &Media{}
}
func newAccountData(_ *dbutil.QueryHelper[*AccountData]) *AccountData {
return &AccountData{}
}
func newAccount(_ *dbutil.QueryHelper[*Account]) *Account {
return &Account{}
}
func newSpaceEdge(_ *dbutil.QueryHelper[*SpaceEdge]) *SpaceEdge {
return &SpaceEdge{}
}
func newPushRegistration(_ *dbutil.QueryHelper[*PushRegistration]) *PushRegistration {
return &PushRegistration{}
}

580
pkg/hicli/database/event.go Normal file
View file

@ -0,0 +1,580 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"github.com/tidwall/gjson"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exgjson"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
const (
getEventBaseQuery = `
SELECT rowid, -1,
room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type,
unsigned, local_content, transaction_id, redacted_by, relates_to, relation_type,
megolm_session_id, decryption_error, send_error, reactions, last_edit_rowid, unread_type
FROM event
`
getEventByRowID = getEventBaseQuery + `WHERE rowid = $1`
getManyEventsByRowID = getEventBaseQuery + `WHERE rowid IN (%s)`
getEventByID = getEventBaseQuery + `WHERE event_id = $1`
getEventByTransactionID = getEventBaseQuery + `WHERE transaction_id = $1`
getFailedEventsByMegolmSessionID = getEventBaseQuery + `WHERE room_id = $1 AND megolm_session_id = $2 AND decryption_error IS NOT NULL`
getRelatedEventsQuery = getEventBaseQuery + `
WHERE room_id = $1 AND relates_to = $2 AND ($3 = '' OR relation_type = $3)
ORDER BY timestamp ASC
`
insertEventBaseQuery = `
INSERT INTO event (
room_id, event_id, sender, type, state_key, timestamp, content, decrypted, decrypted_type,
unsigned, local_content, transaction_id, redacted_by, relates_to, relation_type,
megolm_session_id, decryption_error, send_error, reactions, last_edit_rowid, unread_type
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
`
insertEventQuery = insertEventBaseQuery + `RETURNING rowid`
upsertEventQuery = insertEventBaseQuery + `
ON CONFLICT (event_id) DO UPDATE
SET decrypted=COALESCE(event.decrypted, excluded.decrypted),
decrypted_type=COALESCE(event.decrypted_type, excluded.decrypted_type),
redacted_by=COALESCE(event.redacted_by, excluded.redacted_by),
decryption_error=CASE WHEN COALESCE(event.decrypted, excluded.decrypted) IS NULL THEN COALESCE(excluded.decryption_error, event.decryption_error) END,
send_error=excluded.send_error,
timestamp=excluded.timestamp,
unsigned=COALESCE(excluded.unsigned, event.unsigned),
local_content=COALESCE(excluded.local_content, event.local_content)
ON CONFLICT (transaction_id) DO UPDATE
SET event_id=excluded.event_id,
timestamp=excluded.timestamp,
unsigned=excluded.unsigned
RETURNING rowid
`
updateEventSendErrorQuery = `UPDATE event SET send_error = $2 WHERE rowid = $1`
updateEventIDQuery = `UPDATE event SET event_id = $2, send_error = NULL WHERE rowid=$1`
updateEventDecryptedQuery = `UPDATE event SET decrypted = $2, decrypted_type = $3, decryption_error = NULL, unread_type = $4, local_content = $5 WHERE rowid = $1`
updateEventLocalContentQuery = `UPDATE event SET local_content = $2 WHERE rowid = $1`
updateEventEncryptedContentQuery = `UPDATE event SET content = $2, megolm_session_id = $3 WHERE rowid = $1`
getEventReactionsQuery = getEventBaseQuery + `
WHERE room_id = ?
AND type = 'm.reaction'
AND relation_type = 'm.annotation'
AND redacted_by IS NULL
AND relates_to IN (%s)
`
getEventEditRowIDsQuery = `
SELECT main.event_id, edit.rowid
FROM event main
JOIN event edit ON
edit.room_id = main.room_id
AND edit.relates_to = main.event_id
AND edit.relation_type = 'm.replace'
AND edit.type = main.type
AND edit.sender = main.sender
AND edit.redacted_by IS NULL
WHERE main.event_id IN (%s)
ORDER BY main.event_id, edit.timestamp
`
setLastEditRowIDQuery = `
UPDATE event SET last_edit_rowid = $2 WHERE event_id = $1
`
updateReactionCountsQuery = `UPDATE event SET reactions = $2 WHERE event_id = $1`
)
type EventQuery struct {
*dbutil.QueryHelper[*Event]
}
func (eq *EventQuery) GetFailedByMegolmSessionID(ctx context.Context, roomID id.RoomID, sessionID id.SessionID) ([]*Event, error) {
return eq.QueryMany(ctx, getFailedEventsByMegolmSessionID, roomID, sessionID)
}
func (eq *EventQuery) GetByID(ctx context.Context, eventID id.EventID) (*Event, error) {
return eq.QueryOne(ctx, getEventByID, eventID)
}
func (eq *EventQuery) GetByTransactionID(ctx context.Context, txnID string) (*Event, error) {
return eq.QueryOne(ctx, getEventByTransactionID, txnID)
}
func (eq *EventQuery) GetByRowID(ctx context.Context, rowID EventRowID) (*Event, error) {
return eq.QueryOne(ctx, getEventByRowID, rowID)
}
func (eq *EventQuery) GetRelatedEvents(ctx context.Context, roomID id.RoomID, eventID id.EventID, relationType event.RelationType) ([]*Event, error) {
return eq.QueryMany(ctx, getRelatedEventsQuery, roomID, eventID, relationType)
}
func (eq *EventQuery) GetByRowIDs(ctx context.Context, rowIDs ...EventRowID) ([]*Event, error) {
query, params := buildMultiEventGetFunction(nil, rowIDs, getManyEventsByRowID)
return eq.QueryMany(ctx, query, params...)
}
func (eq *EventQuery) Upsert(ctx context.Context, evt *Event) (rowID EventRowID, err error) {
err = eq.GetDB().QueryRow(ctx, upsertEventQuery, evt.sqlVariables()...).Scan(&rowID)
if err == nil {
evt.RowID = rowID
}
return
}
func (eq *EventQuery) Insert(ctx context.Context, evt *Event) (rowID EventRowID, err error) {
err = eq.GetDB().QueryRow(ctx, insertEventQuery, evt.sqlVariables()...).Scan(&rowID)
if err == nil {
evt.RowID = rowID
}
return
}
var stateEventMassInserter = dbutil.NewMassInsertBuilder[*Event, [1]any](
strings.ReplaceAll(upsertEventQuery, "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)", "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"),
"($1, $%d, $%d, $%d, $%d, $%d, $%d, NULL, NULL, $%d, NULL, $%d, $%d, NULL, NULL, NULL, NULL, NULL, '{}', 0, 0)",
)
var massInsertConverter = dbutil.ConvertRowFn[EventRowID](dbutil.ScanSingleColumn[EventRowID])
func (e *Event) GetMassInsertValues() [9]any {
return [9]any{
e.ID, e.Sender, e.Type, e.StateKey, e.Timestamp.UnixMilli(),
unsafeJSONString(e.Content), unsafeJSONString(e.Unsigned),
dbutil.StrPtr(e.TransactionID), dbutil.StrPtr(e.RedactedBy),
}
}
func (eq *EventQuery) MassUpsertState(ctx context.Context, evts []*Event) error {
for chunk := range slices.Chunk(evts, 500) {
query, params := stateEventMassInserter.Build([1]any{chunk[0].RoomID}, chunk)
i := 0
err := massInsertConverter.
NewRowIter(eq.GetDB().Query(ctx, query, params...)).
Iter(func(t EventRowID) (bool, error) {
chunk[i].RowID = t
i++
return true, nil
})
if err != nil {
return err
}
}
return nil
}
func (eq *EventQuery) UpdateID(ctx context.Context, rowID EventRowID, newID id.EventID) error {
return eq.Exec(ctx, updateEventIDQuery, rowID, newID)
}
func (eq *EventQuery) UpdateSendError(ctx context.Context, rowID EventRowID, sendError string) error {
return eq.Exec(ctx, updateEventSendErrorQuery, rowID, sendError)
}
func (eq *EventQuery) UpdateDecrypted(ctx context.Context, evt *Event) error {
return eq.Exec(
ctx,
updateEventDecryptedQuery,
evt.RowID,
unsafeJSONString(evt.Decrypted),
evt.DecryptedType,
evt.UnreadType,
dbutil.JSONPtr(evt.LocalContent),
)
}
func (eq *EventQuery) UpdateLocalContent(ctx context.Context, evt *Event) error {
return eq.Exec(ctx, updateEventLocalContentQuery, evt.RowID, dbutil.JSONPtr(evt.LocalContent))
}
func (eq *EventQuery) UpdateEncryptedContent(ctx context.Context, evt *Event) error {
return eq.Exec(ctx, updateEventEncryptedContentQuery, evt.RowID, unsafeJSONString(evt.Content), evt.MegolmSessionID)
}
func (eq *EventQuery) FillReactionCounts(ctx context.Context, roomID id.RoomID, events []*Event) error {
eventIDs := make([]id.EventID, 0, len(events))
eventMap := make(map[id.EventID]*Event)
for _, evt := range events {
if evt.Reactions == nil {
eventIDs = append(eventIDs, evt.ID)
eventMap[evt.ID] = evt
}
}
if len(eventIDs) == 0 {
return nil
}
result, err := eq.GetReactions(ctx, roomID, eventIDs...)
if err != nil {
return err
}
for evtID, res := range result {
eventMap[evtID].Reactions = res.Counts
}
return nil
}
func (eq *EventQuery) FillLastEditRowIDs(ctx context.Context, roomID id.RoomID, events []*Event) error {
eventIDs := make([]id.EventID, len(events))
eventMap := make(map[id.EventID]*Event)
for i, evt := range events {
if evt.LastEditRowID == nil {
eventIDs[i] = evt.ID
eventMap[evt.ID] = evt
}
}
return eq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
result, err := eq.GetEditRowIDs(ctx, roomID, eventIDs...)
if err != nil {
return err
}
for evtID, res := range result {
lastEditRowID := res[len(res)-1]
eventMap[evtID].LastEditRowID = &lastEditRowID
delete(eventMap, evtID)
err = eq.Exec(ctx, setLastEditRowIDQuery, evtID, lastEditRowID)
if err != nil {
return err
}
}
var zero EventRowID
for evtID, evt := range eventMap {
evt.LastEditRowID = &zero
err = eq.Exec(ctx, setLastEditRowIDQuery, evtID, zero)
if err != nil {
return err
}
}
return nil
})
}
var reactionKeyPath = exgjson.Path("m.relates_to", "key")
type GetReactionsResult struct {
Events []*Event
Counts map[string]int
}
func buildMultiEventGetFunction[T any](preParams []any, eventIDs []T, query string) (string, []any) {
params := make([]any, len(preParams)+len(eventIDs))
copy(params, preParams)
for i, evtID := range eventIDs {
params[i+len(preParams)] = evtID
}
placeholders := strings.Repeat("?,", len(eventIDs))
placeholders = placeholders[:len(placeholders)-1]
return fmt.Sprintf(query, placeholders), params
}
type editRowIDTuple struct {
eventID id.EventID
editRowID EventRowID
}
func (eq *EventQuery) GetEditRowIDs(ctx context.Context, roomID id.RoomID, eventIDs ...id.EventID) (map[id.EventID][]EventRowID, error) {
query, params := buildMultiEventGetFunction([]any{roomID}, eventIDs, getEventEditRowIDsQuery)
rows, err := eq.GetDB().Query(ctx, query, params...)
output := make(map[id.EventID][]EventRowID)
return output, dbutil.NewRowIterWithError(rows, func(row dbutil.Scannable) (tuple editRowIDTuple, err error) {
err = row.Scan(&tuple.eventID, &tuple.editRowID)
return
}, err).Iter(func(tuple editRowIDTuple) (bool, error) {
output[tuple.eventID] = append(output[tuple.eventID], tuple.editRowID)
return true, nil
})
}
func (eq *EventQuery) GetReactions(ctx context.Context, roomID id.RoomID, eventIDs ...id.EventID) (map[id.EventID]*GetReactionsResult, error) {
result := make(map[id.EventID]*GetReactionsResult, len(eventIDs))
for _, evtID := range eventIDs {
result[evtID] = &GetReactionsResult{Counts: make(map[string]int)}
}
return result, eq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
query, params := buildMultiEventGetFunction([]any{roomID}, eventIDs, getEventReactionsQuery)
events, err := eq.QueryMany(ctx, query, params...)
if err != nil {
return err
} else if len(events) == 0 {
return nil
}
for _, evt := range events {
dest := result[evt.RelatesTo]
dest.Events = append(dest.Events, evt)
keyRes := gjson.GetBytes(evt.Content, reactionKeyPath)
if keyRes.Type == gjson.String {
dest.Counts[keyRes.Str]++
}
}
for evtID, res := range result {
if len(res.Counts) > 0 {
err = eq.Exec(ctx, updateReactionCountsQuery, evtID, dbutil.JSON{Data: &res.Counts})
if err != nil {
return err
}
}
}
return nil
})
}
type EventRowID int64
func (m EventRowID) GetMassInsertValues() [1]any {
return [1]any{m}
}
type LocalContent struct {
SanitizedHTML string `json:"sanitized_html,omitempty"`
HTMLVersion int `json:"html_version,omitempty"`
WasPlaintext bool `json:"was_plaintext,omitempty"`
BigEmoji bool `json:"big_emoji,omitempty"`
HasMath bool `json:"has_math,omitempty"`
EditSource string `json:"edit_source,omitempty"`
ReplyFallbackRemoved bool `json:"reply_fallback_removed,omitempty"`
}
func (c *LocalContent) GetReplyFallbackRemoved() bool {
return c != nil && c.ReplyFallbackRemoved
}
type Event struct {
RowID EventRowID `json:"rowid"`
TimelineRowID TimelineRowID `json:"timeline_rowid"`
RoomID id.RoomID `json:"room_id"`
ID id.EventID `json:"event_id"`
Sender id.UserID `json:"sender"`
Type string `json:"type"`
StateKey *string `json:"state_key,omitempty"`
Timestamp jsontime.UnixMilli `json:"timestamp"`
Content json.RawMessage `json:"content"`
Decrypted json.RawMessage `json:"decrypted,omitempty"`
DecryptedType string `json:"decrypted_type,omitempty"`
Unsigned json.RawMessage `json:"unsigned,omitempty"`
LocalContent *LocalContent `json:"local_content,omitempty"`
TransactionID string `json:"transaction_id,omitempty"`
RedactedBy id.EventID `json:"redacted_by,omitempty"`
RelatesTo id.EventID `json:"relates_to,omitempty"`
RelationType event.RelationType `json:"relation_type,omitempty"`
MegolmSessionID id.SessionID `json:"-"`
DecryptionError string `json:"decryption_error,omitempty"`
SendError string `json:"send_error,omitempty"`
Reactions map[string]int `json:"reactions,omitempty"`
LastEditRowID *EventRowID `json:"last_edit_rowid,omitempty"`
UnreadType UnreadType `json:"unread_type,omitempty"`
}
func MautrixToEvent(evt *event.Event) *Event {
dbEvt := &Event{
RoomID: evt.RoomID,
ID: evt.ID,
Sender: evt.Sender,
Type: evt.Type.Type,
StateKey: evt.StateKey,
Timestamp: jsontime.UM(time.UnixMilli(evt.Timestamp)),
Content: evt.Content.VeryRaw,
MegolmSessionID: getMegolmSessionID(evt),
TransactionID: evt.Unsigned.TransactionID,
Reactions: make(map[string]int),
}
if !strings.HasPrefix(dbEvt.TransactionID, "hicli-mautrix-go_") {
dbEvt.TransactionID = ""
}
dbEvt.RelatesTo, dbEvt.RelationType = getRelatesToFromEvent(evt)
dbEvt.Unsigned, _ = json.Marshal(&evt.Unsigned)
if evt.Unsigned.RedactedBecause != nil {
dbEvt.RedactedBy = evt.Unsigned.RedactedBecause.ID
}
return dbEvt
}
func (e *Event) AsRawMautrix() *event.Event {
if e == nil {
return nil
}
evt := &event.Event{
RoomID: e.RoomID,
ID: e.ID,
Sender: e.Sender,
Type: event.Type{Type: e.Type, Class: event.MessageEventType},
StateKey: e.StateKey,
Timestamp: e.Timestamp.UnixMilli(),
Content: event.Content{VeryRaw: e.Content},
}
if e.Decrypted != nil {
evt.Content.VeryRaw = e.Decrypted
evt.Type.Type = e.DecryptedType
evt.Mautrix.WasEncrypted = true
}
if e.StateKey != nil {
evt.Type.Class = event.StateEventType
}
_ = json.Unmarshal(e.Unsigned, &evt.Unsigned)
return evt
}
func (e *Event) Scan(row dbutil.Scannable) (*Event, error) {
var timestamp int64
var transactionID, redactedBy, relatesTo, relationType, megolmSessionID, decryptionError, sendError, decryptedType sql.NullString
err := row.Scan(
&e.RowID,
&e.TimelineRowID,
&e.RoomID,
&e.ID,
&e.Sender,
&e.Type,
&e.StateKey,
&timestamp,
(*[]byte)(&e.Content),
(*[]byte)(&e.Decrypted),
&decryptedType,
(*[]byte)(&e.Unsigned),
dbutil.JSON{Data: &e.LocalContent},
&transactionID,
&redactedBy,
&relatesTo,
&relationType,
&megolmSessionID,
&decryptionError,
&sendError,
dbutil.JSON{Data: &e.Reactions},
&e.LastEditRowID,
&e.UnreadType,
)
if err != nil {
return nil, err
}
e.Timestamp = jsontime.UM(time.UnixMilli(timestamp))
e.TransactionID = transactionID.String
e.RedactedBy = id.EventID(redactedBy.String)
e.RelatesTo = id.EventID(relatesTo.String)
e.RelationType = event.RelationType(relationType.String)
e.MegolmSessionID = id.SessionID(megolmSessionID.String)
e.DecryptedType = decryptedType.String
e.DecryptionError = decryptionError.String
e.SendError = sendError.String
return e, nil
}
var relatesToPath = exgjson.Path("m.relates_to", "event_id")
var relationTypePath = exgjson.Path("m.relates_to", "rel_type")
var replyToPath = exgjson.Path("m.relates_to", "m.in_reply_to", "event_id")
func getRelatesToFromEvent(evt *event.Event) (id.EventID, event.RelationType) {
if evt.StateKey != nil {
return "", ""
}
return GetRelatesToFromBytes(evt.Content.VeryRaw)
}
func GetRelatesToFromBytes(content []byte) (id.EventID, event.RelationType) {
results := gjson.GetManyBytes(content, relatesToPath, relationTypePath)
if len(results) == 2 && results[0].Exists() && results[1].Exists() && results[0].Type == gjson.String && results[1].Type == gjson.String {
return id.EventID(results[0].Str), event.RelationType(results[1].Str)
}
return "", ""
}
func getMegolmSessionID(evt *event.Event) id.SessionID {
if evt.Type != event.EventEncrypted {
return ""
}
res := gjson.GetBytes(evt.Content.VeryRaw, "session_id")
if res.Exists() && res.Type == gjson.String {
return id.SessionID(res.Str)
}
return ""
}
func (e *Event) GetReplyTo() id.EventID {
content := e.Content
if e.Decrypted != nil {
content = e.Decrypted
}
result := gjson.GetBytes(content, replyToPath)
if result.Type == gjson.String {
return id.EventID(result.Str)
}
return ""
}
func (e *Event) sqlVariables() []any {
var reactions any
if e.Reactions != nil {
reactions = e.Reactions
}
return []any{
e.RoomID,
e.ID,
e.Sender,
e.Type,
e.StateKey,
e.Timestamp.UnixMilli(),
unsafeJSONString(e.Content),
unsafeJSONString(e.Decrypted),
dbutil.StrPtr(e.DecryptedType),
unsafeJSONString(e.Unsigned),
dbutil.JSONPtr(e.LocalContent),
dbutil.StrPtr(e.TransactionID),
dbutil.StrPtr(e.RedactedBy),
dbutil.StrPtr(e.RelatesTo),
dbutil.StrPtr(e.RelationType),
dbutil.StrPtr(e.MegolmSessionID),
dbutil.StrPtr(e.DecryptionError),
dbutil.StrPtr(e.SendError),
dbutil.JSON{Data: reactions},
e.LastEditRowID,
e.UnreadType,
}
}
func (e *Event) GetNonPushUnreadType() UnreadType {
if e.RelationType == event.RelReplace || e.RedactedBy != "" {
return UnreadTypeNone
}
switch e.Type {
case event.EventMessage.Type, event.EventSticker.Type, event.EventUnstablePollStart.Type:
return UnreadTypeNormal
case event.EventEncrypted.Type:
switch e.DecryptedType {
case event.EventMessage.Type, event.EventSticker.Type, event.EventUnstablePollStart.Type:
return UnreadTypeNormal
}
}
return UnreadTypeNone
}
func (e *Event) CanUseForPreview() bool {
return (e.Type == event.EventMessage.Type || e.Type == event.EventSticker.Type ||
(e.Type == event.EventEncrypted.Type &&
(e.DecryptedType == event.EventMessage.Type || e.DecryptedType == event.EventSticker.Type))) &&
e.RelationType != event.RelReplace && e.RedactedBy == ""
}
func (e *Event) BumpsSortingTimestamp() bool {
return (e.Type == event.EventMessage.Type || e.Type == event.EventSticker.Type || e.Type == event.EventEncrypted.Type) &&
e.RelationType != event.RelReplace
}
func (e *Event) MarkReplyFallbackRemoved() {
if e.LocalContent == nil {
e.LocalContent = &LocalContent{}
}
e.LocalContent.ReplyFallbackRemoved = true
}

View file

@ -0,0 +1,73 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
const (
getInvitedRoomsQuery = `
SELECT room_id, received_at, invite_state
FROM invited_room
ORDER BY received_at DESC
`
deleteInvitedRoomQuery = `
DELETE FROM invited_room WHERE room_id = $1
`
upsertInvitedRoomQuery = `
INSERT INTO invited_room (room_id, received_at, invite_state)
VALUES ($1, $2, $3)
ON CONFLICT (room_id) DO UPDATE
SET received_at = $2, invite_state = $3
`
)
type InvitedRoomQuery struct {
*dbutil.QueryHelper[*InvitedRoom]
}
func (irq *InvitedRoomQuery) GetAll(ctx context.Context) ([]*InvitedRoom, error) {
return irq.QueryMany(ctx, getInvitedRoomsQuery)
}
func (irq *InvitedRoomQuery) Upsert(ctx context.Context, room *InvitedRoom) error {
return irq.Exec(ctx, upsertInvitedRoomQuery, room.sqlVariables()...)
}
func (irq *InvitedRoomQuery) Delete(ctx context.Context, roomID id.RoomID) error {
return irq.Exec(ctx, deleteInvitedRoomQuery, roomID)
}
type InvitedRoom struct {
ID id.RoomID `json:"room_id"`
CreatedAt jsontime.UnixMilli `json:"created_at"`
InviteState []*event.Event `json:"invite_state"`
}
func (r *InvitedRoom) sqlVariables() []any {
return []any{
r.ID,
dbutil.UnixMilliPtr(r.CreatedAt.Time),
dbutil.JSON{Data: &r.InviteState},
}
}
func (r *InvitedRoom) Scan(row dbutil.Scannable) (*InvitedRoom, error) {
var createdAt int64
err := row.Scan(&r.ID, &createdAt, dbutil.JSON{Data: &r.InviteState})
if err != nil {
return nil, err
}
r.CreatedAt = jsontime.UMInt(createdAt)
return r, nil
}

239
pkg/hicli/database/media.go Normal file
View file

@ -0,0 +1,239 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"database/sql"
"fmt"
"net/http"
"slices"
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/id"
)
const (
insertMediaQuery = `
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (mxc) DO NOTHING
`
upsertMediaQuery = `
INSERT INTO media (mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (mxc) DO UPDATE
SET enc_file = COALESCE(excluded.enc_file, media.enc_file),
file_name = COALESCE(excluded.file_name, media.file_name),
mime_type = COALESCE(excluded.mime_type, media.mime_type),
size = COALESCE(excluded.size, media.size),
hash = COALESCE(excluded.hash, media.hash),
error = excluded.error,
thumbnail_size = COALESCE(excluded.thumbnail_size, media.thumbnail_size),
thumbnail_hash = COALESCE(excluded.thumbnail_hash, media.thumbnail_hash),
thumbnail_error = excluded.thumbnail_error
WHERE excluded.error IS NULL OR media.hash IS NULL
`
getMediaQuery = `
SELECT mxc, enc_file, file_name, mime_type, size, hash, error, thumbnail_size, thumbnail_hash, thumbnail_error
FROM media
WHERE mxc = $1
`
addMediaReferenceQuery = `
INSERT INTO media_reference (event_rowid, media_mxc)
VALUES ($1, $2)
ON CONFLICT (event_rowid, media_mxc) DO NOTHING
`
)
var mediaReferenceMassInserter = dbutil.NewMassInsertBuilder[*MediaReference, [0]any](
addMediaReferenceQuery, "($%d, $%d)",
)
var mediaMassInserter = dbutil.NewMassInsertBuilder[*PlainMedia, [0]any](
"INSERT INTO media (mxc) VALUES ($1) ON CONFLICT (mxc) DO NOTHING", "($%d)",
)
type MediaQuery struct {
*dbutil.QueryHelper[*Media]
}
func (mq *MediaQuery) Add(ctx context.Context, cm *Media) error {
return mq.Exec(ctx, insertMediaQuery, cm.sqlVariables()...)
}
func (mq *MediaQuery) AddReference(ctx context.Context, evtRowID EventRowID, mxc id.ContentURI) error {
return mq.Exec(ctx, addMediaReferenceQuery, evtRowID, &mxc)
}
func (mq *MediaQuery) AddMany(ctx context.Context, medias []*PlainMedia) error {
for chunk := range slices.Chunk(medias, 8000) {
query, params := mediaMassInserter.Build([0]any{}, chunk)
err := mq.Exec(ctx, query, params...)
if err != nil {
return err
}
}
return nil
}
func (mq *MediaQuery) AddManyReferences(ctx context.Context, refs []*MediaReference) error {
for chunk := range slices.Chunk(refs, 4000) {
query, params := mediaReferenceMassInserter.Build([0]any{}, chunk)
err := mq.Exec(ctx, query, params...)
if err != nil {
return err
}
}
return nil
}
func (mq *MediaQuery) Put(ctx context.Context, cm *Media) error {
return mq.Exec(ctx, upsertMediaQuery, cm.sqlVariables()...)
}
func (mq *MediaQuery) Get(ctx context.Context, mxc id.ContentURI) (*Media, error) {
return mq.QueryOne(ctx, getMediaQuery, &mxc)
}
type MediaError struct {
Matrix *mautrix.RespError `json:"data"`
StatusCode int `json:"status_code"`
ReceivedAt jsontime.UnixMilli `json:"received_at"`
Attempts int `json:"attempts"`
}
const MaxMediaBackoff = 7 * 24 * time.Hour
func (me *MediaError) backoff() time.Duration {
return min(time.Duration(2<<me.Attempts)*time.Second, MaxMediaBackoff)
}
func (me *MediaError) UseCache() bool {
return me != nil && time.Since(me.ReceivedAt.Time) < me.backoff()
}
func (me *MediaError) Write(w http.ResponseWriter) {
if me.Matrix.ExtraData == nil {
me.Matrix.ExtraData = make(map[string]any)
}
me.Matrix.ExtraData["fi.mau.hicli.error_ts"] = me.ReceivedAt.UnixMilli()
me.Matrix.ExtraData["fi.mau.hicli.next_retry_ts"] = me.ReceivedAt.Add(me.backoff()).UnixMilli()
w.Header().Set("Mau-Errored-At", me.ReceivedAt.Format(http.TimeFormat))
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", max(int(time.Until(me.ReceivedAt.Add(me.backoff())).Seconds()), 0)))
me.Matrix.WithStatus(me.StatusCode).Write(w)
}
type Media struct {
MXC id.ContentURI
EncFile *attachment.EncryptedFile
FileName string
MimeType string
Size int64
Hash *[32]byte
Error *MediaError
ThumbnailError string
ThumbnailSize int64
ThumbnailHash *[32]byte
}
func (m *Media) ETag(thumbnail bool) string {
if m == nil {
return ""
}
if thumbnail {
if m.ThumbnailHash == nil {
return ""
}
return fmt.Sprintf(`"%x"`, m.ThumbnailHash)
}
if m.Hash == nil {
return ""
}
return fmt.Sprintf(`"%x"`, m.Hash)
}
func (m *Media) UseCache() bool {
return m != nil && (m.Hash != nil || m.Error.UseCache())
}
func (m *Media) sqlVariables() []any {
var hash, thumbnailHash []byte
if m.Hash != nil {
hash = m.Hash[:]
}
if m.ThumbnailHash != nil {
thumbnailHash = m.ThumbnailHash[:]
}
return []any{
&m.MXC, dbutil.JSONPtr(m.EncFile),
dbutil.StrPtr(m.FileName), dbutil.StrPtr(m.MimeType), dbutil.NumPtr(m.Size),
hash, dbutil.JSONPtr(m.Error),
dbutil.NumPtr(m.ThumbnailSize), thumbnailHash, dbutil.StrPtr(m.ThumbnailError),
}
}
var safeMimes = []string{
"text/css", "text/plain", "text/csv",
"application/json", "application/ld+json",
"image/jpeg", "image/gif", "image/png", "image/apng", "image/webp", "image/avif",
"video/mp4", "video/webm", "video/ogg", "video/quicktime",
"audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave",
"audio/wav", "audio/x-wav", "audio/x-pn-wav", "audio/flac", "audio/x-flac",
}
func (m *Media) Scan(row dbutil.Scannable) (*Media, error) {
var mimeType, fileName, thumbnailError sql.NullString
var size, thumbnailSize sql.NullInt64
var hash, thumbnailHash []byte
err := row.Scan(
&m.MXC, dbutil.JSON{Data: &m.EncFile}, &fileName, &mimeType, &size,
&hash, dbutil.JSON{Data: &m.Error}, &thumbnailSize, &thumbnailHash, &thumbnailError,
)
if err != nil {
return nil, err
}
m.MimeType = mimeType.String
m.FileName = fileName.String
m.Size = size.Int64
m.ThumbnailSize = thumbnailSize.Int64
m.ThumbnailError = thumbnailError.String
if len(hash) == 32 {
m.Hash = (*[32]byte)(hash)
}
if len(thumbnailHash) == 32 {
m.ThumbnailHash = (*[32]byte)(thumbnailHash)
}
return m, nil
}
func (m *Media) ContentDisposition() string {
if slices.Contains(safeMimes, m.MimeType) {
return "inline"
}
return "attachment"
}
type MediaReference struct {
EventRowID EventRowID
MediaMXC id.ContentURI
}
func (mr *MediaReference) GetMassInsertValues() [2]any {
return [2]any{mr.EventRowID, &mr.MediaMXC}
}
type PlainMedia id.ContentURI
func (pm *PlainMedia) GetMassInsertValues() [1]any {
return [1]any{(*id.ContentURI)(pm)}
}

View file

@ -0,0 +1,78 @@
// Copyright (c) 2025 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"encoding/json"
"time"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/jsontime"
)
const (
getNonExpiredPushTargets = `
SELECT device_id, type, data, encryption, expiration
FROM push_registration
WHERE expiration > $1
`
putPushRegistration = `
INSERT INTO push_registration (device_id, type, data, encryption, expiration)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (device_id) DO UPDATE SET
type = EXCLUDED.type,
data = EXCLUDED.data,
encryption = EXCLUDED.encryption,
expiration = EXCLUDED.expiration
`
)
type PushRegistrationQuery struct {
*dbutil.QueryHelper[*PushRegistration]
}
func (prq *PushRegistrationQuery) Put(ctx context.Context, reg *PushRegistration) error {
return prq.Exec(ctx, putPushRegistration, reg.sqlVariables()...)
}
func (seq *PushRegistrationQuery) GetAll(ctx context.Context) ([]*PushRegistration, error) {
return seq.QueryMany(ctx, getNonExpiredPushTargets, time.Now().Unix())
}
type PushType string
const (
PushTypeFCM PushType = "fcm"
)
type EncryptionKey struct {
Key []byte `json:"key,omitempty"`
}
type PushRegistration struct {
DeviceID string `json:"device_id"`
Type PushType `json:"type"`
Data json.RawMessage `json:"data"`
Encryption EncryptionKey `json:"encryption"`
Expiration jsontime.Unix `json:"expiration"`
}
func (pe *PushRegistration) Scan(row dbutil.Scannable) (*PushRegistration, error) {
err := row.Scan(&pe.DeviceID, &pe.Type, (*[]byte)(&pe.Data), dbutil.JSON{Data: &pe.Encryption}, &pe.Expiration)
if err != nil {
return nil, err
}
return pe, nil
}
func (pe *PushRegistration) sqlVariables() []any {
if pe.Expiration.IsZero() {
pe.Expiration = jsontime.U(time.Now().Add(7 * 24 * time.Hour))
}
return []interface{}{pe.DeviceID, pe.Type, unsafeJSONString(pe.Data), dbutil.JSON{Data: &pe.Encryption}, pe.Expiration}
}

Some files were not shown because too many files have changed in this diff Show more