1package main
2
3import (
4 "encoding/hex"
5 "errors"
6 "flag"
7 "fmt"
8 "hash/fnv"
9 "io"
10 "os"
11 "os/signal"
12 "os/user"
13 "path"
14 "path/filepath"
15 "runtime"
16 "strings"
17 "syscall"
18 "time"
19
20 "git.sr.ht/~delthas/senpai"
21)
22
23func main() {
24 var configPath string
25 var nickname string
26 var debug bool
27 var version bool
28 flag.StringVar(&configPath, "config", "", "path to the configuration file")
29 flag.StringVar(&nickname, "nickname", "", "nick name/display name to use")
30 flag.BoolVar(&debug, "debug", false, "show raw protocol data in the home buffer")
31 flag.BoolVar(&version, "version", false, "show version info")
32 flag.Parse()
33
34 if version {
35 if v, ok := senpai.BuildVersion(); ok {
36 fmt.Printf("senpai version %v\n", v)
37 } else {
38 fmt.Printf("senpai (unknown version)\n")
39 }
40 return
41 }
42
43 var previousConfigPath string
44 if configPath == "" {
45 configDir, err := os.UserConfigDir()
46 if err != nil {
47 panic(err)
48 }
49 if runtime.GOOS == "darwin" {
50 if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" && dir != configDir {
51 previousConfigPath = configDir
52 configDir = dir
53 }
54 }
55 configPath = path.Join(configDir, "senpai", "senpai.scfg")
56 if previousConfigPath != "" {
57 previousConfigPath = path.Join(previousConfigPath, "senpai", "senpai.scfg")
58 }
59 }
60
61 cfg, err := senpai.LoadConfigFile(configPath)
62 if err != nil && errors.Is(err, os.ErrNotExist) && previousConfigPath != "" {
63 var ee error
64 cfg, ee = senpai.LoadConfigFile(previousConfigPath)
65 if ee == nil {
66 err = nil
67 fmt.Fprintf(os.Stderr, "Configuration assistant: Previous configuration file found at %q; the new default configuration location is now %q\n", previousConfigPath, configPath)
68 fmt.Fprintf(os.Stderr, "Configuration assistant: You can run senpai with an explicit -config path argument, or move the file to its new location.\n")
69 fmt.Fprintf(os.Stderr, "Configuration assistant: Would you like senpai to move the configuration file to the new location? (examples: yes, no) [optional, default: yes]: ")
70 move := true
71 scanBool(&move)
72 if move {
73 if err := copyContents(configPath, previousConfigPath); err != nil {
74 fmt.Fprintf(os.Stderr, "failed to move the configuration file: %v\n", err)
75 os.Exit(1)
76 return
77 }
78 if err := os.Remove(previousConfigPath); err != nil {
79 fmt.Fprintf(os.Stderr, "failed to delete the previous configuration file at %q: %v\n", previousConfigPath, err)
80 os.Exit(1)
81 return
82 }
83 }
84 }
85 }
86 if err != nil {
87 if !errors.Is(err, os.ErrNotExist) {
88 fmt.Fprintf(os.Stderr, "failed to load the required configuration file at %q: %s\n", configPath, err)
89 os.Exit(1)
90 return
91 }
92 var host, port string
93 tls := true
94 var nick, password string
95 fmt.Fprintf(os.Stderr, "The configuration file at %q was not found.\n", configPath)
96 fmt.Fprintf(os.Stderr, "Configuration assistant: senpai will create a configuration file for you.\n\n")
97 fmt.Fprintf(os.Stderr, "Important senpai information:\n")
98 fmt.Fprintf(os.Stderr, "* senpai is able to connect to at most 1 server at a time.\n")
99 fmt.Fprintf(os.Stderr, "* In order to connect to multiple networks, keep message history, search through your messages, and upload files, use an \x1B[1mIRC bouncer\x1B[0m and point senpai to the bouncer.\n")
100 fmt.Fprintf(os.Stderr, "* Most senpai users use senpai with the IRC bouncer software \x1B[1msoju\x1B[0m.\n")
101 fmt.Fprintf(os.Stderr, "** You can self-host \x1B[1msoju\x1B[0m yourself (it is free and open-source): https://soju.im/\n")
102 fmt.Fprintf(os.Stderr, "** You can also use a commercial hosted bouncer (uses \x1B[1msoju\x1B[0m underneath), endorsed by senpai: \x1B[1;4mhttps://irctoday.com/\x1B[0m\n\n")
103 fmt.Fprintf(os.Stderr, "Feel free to connect to your server now and configure a bouncer later to enable additional features.\n\n")
104 fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your server host (examples: irc.libera.chat, irctoday.com): ")
105 for host == "" {
106 fmt.Scanln(&host)
107 }
108 fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your server port (examples: 6667, 6697) [optional]: ")
109 fmt.Scanln(&port)
110 fmt.Fprintf(os.Stderr, "Configuration assistant: Enter whether your server uses TLS (examples: yes, no) [optional, default: yes]: ")
111 scanBool(&tls)
112 var defaultNick string
113 if u, err := user.Current(); err == nil {
114 defaultNick = u.Username
115 if _, name, ok := strings.Cut(defaultNick, "\\"); ok {
116 defaultNick = name
117 }
118 fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your nickname [optional, default: %v]: ", defaultNick)
119 } else {
120 fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your nickname: ")
121 }
122 fmt.Scanln(&nick)
123 for defaultNick == "" && nick == "" {
124 fmt.Scanln(&nick)
125 }
126 if nick == "" {
127 nick = defaultNick
128 }
129 fmt.Fprintf(os.Stderr, "Configuration assistant: Enter your password (only enter if you already have an account) [optional]: ")
130 fmt.Scanln(&password)
131
132 folderPath := path.Dir(configPath)
133 if err := os.MkdirAll(folderPath, 0700); err != nil {
134 fmt.Fprintf(os.Stderr, "failed to create the configuration file folder at %q: %s\n", folderPath, err)
135 os.Exit(1)
136 return
137 }
138 f, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
139 if err != nil {
140 fmt.Fprintf(os.Stderr, "failed to create the configuration file at %q: %s\n", configPath, err)
141 os.Exit(1)
142 return
143 }
144 var addr string
145 if !tls {
146 addr += "irc+insecure://"
147 }
148 addr += host
149 if port != "" {
150 addr += ":" + port
151 }
152 fmt.Fprintf(f, "address %q\n", addr)
153 fmt.Fprintf(f, "nickname %q\n", nick)
154 if password != "" {
155 fmt.Fprintf(f, "password %q\n", password)
156 }
157 f.Close()
158
159 cfg, err = senpai.LoadConfigFile(configPath)
160 if err != nil {
161 fmt.Fprintf(os.Stderr, "failed to load the configuration file at %q: %s\n", configPath, err)
162 os.Exit(1)
163 return
164 }
165
166 fmt.Fprintf(os.Stderr, "Configuration assistant: Configuration saved to %q. Now starting.", configPath)
167 for i := 0; i < 6; i++ {
168 time.Sleep(500 * time.Millisecond)
169 fmt.Fprintf(os.Stderr, ".")
170 }
171 fmt.Fprintf(os.Stderr, "\n")
172 }
173
174 cfg.Debug = cfg.Debug || debug
175 if nickname != "" {
176 cfg.Nick = nickname
177 }
178
179 app, err := senpai.NewApp(cfg)
180 if err != nil {
181 fmt.Fprintf(os.Stderr, "failed to run: %s\n", err)
182 os.Exit(1)
183 return
184 }
185
186 cfgHash := configPathHash(configPath)
187 if !cfg.Transient {
188 lastNetID, lastBuffer := getLastBuffer(cfgHash)
189 app.SwitchToBuffer(lastNetID, lastBuffer)
190 app.SetLastClose(getLastStamp(cfgHash))
191 }
192
193 sigCh := make(chan os.Signal, 1)
194 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
195
196 go func() {
197 <-sigCh
198 app.Close()
199 }()
200
201 app.Run()
202 app.Close()
203 if !cfg.Transient {
204 writeLastBuffer(app, cfgHash)
205 writeLastStamp(app, cfgHash)
206 }
207}
208
209func scanBool(v *bool) {
210 for {
211 var s string
212 fmt.Scanln(&s)
213 if s == "" {
214 return
215 }
216 switch strings.ToLower(s) {
217 case "y", "yes":
218 *v = true
219 return
220 case "n", "no":
221 *v = false
222 return
223 }
224 }
225}
226
227func copyContents(dstPath string, srcPath string) error {
228 src, err := os.Open(srcPath)
229 if err != nil {
230 return fmt.Errorf("open %q: %v", srcPath, err)
231 }
232 defer src.Close()
233 dst, err := os.Create(dstPath)
234 if err != nil {
235 return fmt.Errorf("create %q: %v", dstPath, err)
236 }
237 defer dst.Close()
238 if _, err := io.Copy(dst, src); err != nil {
239 return fmt.Errorf("copy %q to %q: %v", srcPath, dstPath, err)
240 }
241 return nil
242}
243
244func cachePath() string {
245 cacheDir, err := os.UserCacheDir()
246 if err != nil {
247 panic(err)
248 }
249 cache := path.Join(cacheDir, "senpai")
250 err = os.MkdirAll(cache, 0755)
251 if err != nil {
252 panic(err)
253 }
254 return cache
255}
256
257func configPathHash(configPath string) string {
258 abs, err := filepath.Abs(configPath)
259 if err != nil {
260 abs = configPath
261 }
262 h := fnv.New32a()
263 h.Write([]byte(abs))
264 return hex.EncodeToString(h.Sum(nil))
265}
266
267func lastBufferPath(hash string) string {
268 name := "lastbuffer.txt"
269 if hash != "" {
270 name = "lastbuffer-" + hash + ".txt"
271 }
272 return path.Join(cachePath(), name)
273}
274
275func getLastBuffer(hash string) (netID, buffer string) {
276 p := lastBufferPath(hash)
277 buf, err := os.ReadFile(p)
278 if err != nil && hash != "" {
279 buf, err = os.ReadFile(lastBufferPath(""))
280 if err != nil {
281 return "", ""
282 }
283 } else if err != nil {
284 return "", ""
285 }
286
287 fields := strings.SplitN(strings.TrimSpace(string(buf)), " ", 2)
288 if len(fields) < 2 {
289 return "", ""
290 }
291
292 return fields[0], fields[1]
293}
294
295func writeLastBuffer(app *senpai.App, hash string) {
296 p := lastBufferPath(hash)
297 lastNetID, lastBuffer := app.CurrentBuffer()
298 err := os.WriteFile(p, []byte(fmt.Sprintf("%s %s", lastNetID, lastBuffer)), 0666)
299 if err != nil {
300 fmt.Fprintf(os.Stderr, "failed to write last buffer at %q: %s\n", p, err)
301 }
302}
303
304func lastStampPath(hash string) string {
305 name := "laststamp.txt"
306 if hash != "" {
307 name = "laststamp-" + hash + ".txt"
308 }
309 return path.Join(cachePath(), name)
310}
311
312func getLastStamp(hash string) time.Time {
313 p := lastStampPath(hash)
314 buf, err := os.ReadFile(p)
315 if err != nil && hash != "" {
316 buf, err = os.ReadFile(lastStampPath(""))
317 if err != nil {
318 return time.Time{}
319 }
320 } else if err != nil {
321 return time.Time{}
322 }
323
324 stamp := strings.TrimSpace(string(buf))
325 t, err := time.Parse(time.RFC3339Nano, stamp)
326 if err != nil {
327 return time.Time{}
328 }
329 return t
330}
331
332func writeLastStamp(app *senpai.App, hash string) {
333 p := lastStampPath(hash)
334 last := app.LastMessageTime()
335 if last.IsZero() {
336 return
337 }
338 err := os.WriteFile(p, []byte(last.UTC().Format(time.RFC3339Nano)), 0666)
339 if err != nil {
340 fmt.Fprintf(os.Stderr, "failed to write last stamp at %q: %s\n", p, err)
341 }
342}