-
Notifications
You must be signed in to change notification settings - Fork 127
Expand file tree
/
Copy pathmain.go
More file actions
670 lines (580 loc) · 18.1 KB
/
Copy pathmain.go
File metadata and controls
670 lines (580 loc) · 18.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
//go:build !server
package main
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"log/slog"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"MrRSS/internal/ai"
"MrRSS/internal/database"
"MrRSS/internal/feed"
handlers "MrRSS/internal/handlers/core"
"MrRSS/internal/monitor"
"MrRSS/internal/network"
"MrRSS/internal/routes"
"MrRSS/internal/translation"
"MrRSS/internal/utils/fileutil"
"MrRSS/internal/utils/httputil"
)
var debugLogging = os.Getenv("MRRSS_DEBUG") != ""
func debugLog(format string, args ...interface{}) {
if debugLogging {
log.Printf(format, args...)
}
}
//go:embed frontend/dist
var frontendFiles embed.FS
// Platform-specific icon embedding
// Windows and macOS both use PNG format for system tray
// Windows .ico is only used for executable icon (via syso)
//
//go:embed build/windows/icon.png
var appIconWindows []byte
//go:embed build/appicon.png
var appIconMacOS []byte
// getAppIcon returns the appropriate icon for the current platform
func getAppIcon() []byte {
if runtime.GOOS == "windows" {
return appIconWindows
}
return appIconMacOS
}
type windowState struct {
width int
height int
x int
y int
valid atomic.Bool
maximized atomic.Bool
}
type CombinedHandler struct {
apiMux *http.ServeMux
fileServer http.Handler
}
func (h *CombinedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
h.apiMux.ServeHTTP(w, r)
return
}
h.fileServer.ServeHTTP(w, r)
}
// APIMiddleware routes API requests to the API handler, and lets Wails handle the rest
func APIMiddleware(combinedHandler *CombinedHandler) application.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Let the /wails route be handled by Wails runtime
if strings.HasPrefix(r.URL.Path, "/wails") {
next.ServeHTTP(w, r)
return
}
// Handle API routes and serve static files
combinedHandler.ServeHTTP(w, r)
})
}
}
func main() {
// Get proper paths for data files
logPath, err := fileutil.GetLogPath()
if err != nil {
log.Printf("Warning: Could not get log path: %v. Using current directory.", err)
logPath = "debug.log"
}
// Clear previous log by opening in truncate mode
f, err := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
log.Printf("Failed to open log file: %v", err)
// Fallback to stdout
f = nil
}
if f != nil {
defer f.Close()
log.SetOutput(f)
}
log.Println("Starting application...")
// Log portable mode status
if fileutil.IsPortableMode() {
log.Println("Running in PORTABLE mode")
} else {
log.Println("Running in NORMAL mode")
}
log.Printf("Log file: %s", logPath)
// Get database path
dbPath, err := fileutil.GetDBPath()
if err != nil {
log.Printf("Error getting database path: %v", err)
log.Fatal(err)
}
debugLog("Database path: %s", dbPath)
// Initialize database
log.Println("Initializing Database...")
db, err := database.NewDB(dbPath)
if err != nil {
log.Printf("Error initializing database: %v", err)
log.Fatal(err)
}
// Run database schema initialization synchronously to ensure it's ready
log.Println("Running DB migrations...")
if err := db.Init(); err != nil {
log.Printf("Error initializing database schema: %v", err)
log.Fatal(err)
}
log.Println("Database initialized successfully")
// Initialize AI profile provider
profileProvider := ai.NewProfileProvider(db)
translator := translation.NewDynamicTranslatorWithCache(db, db)
translator.SetProfileProvider(profileProvider)
fetcher := feed.NewFetcher(db)
h := handlers.NewHandler(db, fetcher, translator, profileProvider)
var quitRequested atomic.Bool
var lastWindowState windowState
// API Routes
log.Println("Setting up API routes...")
apiMux := http.NewServeMux()
routes.RegisterAPIRoutes(apiMux, h)
// Static Files
log.Println("Setting up static files...")
frontendFS, err := fs.Sub(frontendFiles, "frontend/dist")
if err != nil {
log.Fatal(err)
}
fileServer := http.FileServer(http.FS(frontendFS))
combinedHandler := &CombinedHandler{
apiMux: apiMux,
fileServer: fileServer,
}
shouldCloseToTray := func() bool {
val, err := db.GetSetting("close_to_tray")
return err == nil && val == "true"
}
// Start background scheduler
log.Println("Starting background scheduler...")
bgCtx, bgCancel := context.WithCancel(context.Background())
// Encryption key for single instance communication (IPC between app instances).
// This key is used to encrypt/decrypt messages between first and subsequent instances.
// Note: This is not for sensitive data encryption - it only carries launch arguments.
// The key is hardcoded per Wails v3 examples since the data exchanged is not sensitive
// (just signals to bring window to front).
var encryptionKey = [32]byte{
0x1e, 0x1f, 0x1c, 0x1d, 0x1a, 0x1b, 0x18, 0x19,
0x16, 0x17, 0x14, 0x15, 0x12, 0x13, 0x10, 0x11,
0x0e, 0x0f, 0x0c, 0x0d, 0x0a, 0x0b, 0x08, 0x09,
0x06, 0x07, 0x04, 0x05, 0x02, 0x03, 0x00, 0x01,
}
// Variable to store the main window reference
var mainWindow application.Window
log.Println("Starting Wails v3...")
// Create new Wails v3 application
app := application.New(application.Options{
Name: "MrRSS",
Description: "A modern, privacy-focused RSS reader",
LogLevel: slog.LevelError,
Assets: application.AssetOptions{
Handler: combinedHandler,
Middleware: APIMiddleware(combinedHandler),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: false,
},
SingleInstance: func() *application.SingleInstanceOptions {
// Disable single instance on Linux due to potential D-Bus issues
if runtime.GOOS == "linux" {
return nil
}
return &application.SingleInstanceOptions{
UniqueID: "com.mrrss.app",
EncryptionKey: encryptionKey,
OnSecondInstanceLaunch: func(data application.SecondInstanceData) {
log.Printf("Second instance detected, bringing window to front")
if mainWindow != nil {
// Restore window state if it was stored (minimized to tray)
if lastWindowState.valid.Load() {
width := lastWindowState.width
height := lastWindowState.height
x := lastWindowState.x
y := lastWindowState.y
// Ensure minimum window size
if width < 400 {
width = 1024
}
if height < 300 {
height = 768
}
// Ensure window is at least partially on screen
if x < -1000 || x > 3000 {
x = 100
}
if y < -1000 || y > 3000 {
y = 100
}
mainWindow.SetSize(width, height)
mainWindow.SetPosition(x, y)
}
// Show and unminimize the window
mainWindow.Show()
if lastWindowState.maximized.Load() {
mainWindow.Maximise()
} else {
mainWindow.Restore()
}
}
},
}
}(),
})
// Set app instance to handler for browser integration
h.SetApp(app)
log.Println("Browser integration enabled")
// Get window dimensions from stored state or defaults
windowWidth := 1024
windowHeight := 768
windowX := 0
windowY := 0
restoredFromDB := false
restoredMaximized := false
// Try to restore window state from database
if x, err := db.GetSetting("window_x"); err == nil && x != "" {
if y, err := db.GetSetting("window_y"); err == nil && y != "" {
if width, err := db.GetSetting("window_width"); err == nil && width != "" {
if height, err := db.GetSetting("window_height"); err == nil && height != "" {
// Parse values
var xInt, yInt, widthInt, heightInt int
if _, err := fmt.Sscanf(x, "%d", &xInt); err == nil {
if _, err := fmt.Sscanf(y, "%d", &yInt); err == nil {
if _, err := fmt.Sscanf(width, "%d", &widthInt); err == nil {
if _, err := fmt.Sscanf(height, "%d", &heightInt); err == nil {
// Validate values
if widthInt >= 400 && heightInt >= 300 && widthInt <= 4000 && heightInt <= 3000 {
if xInt > -1000 && xInt < 3000 && yInt > -1000 && yInt < 3000 {
windowWidth = widthInt
windowHeight = heightInt
windowX = xInt
windowY = yInt
restoredFromDB = true
// Store in memory for minimize-restore
lastWindowState.width = widthInt
lastWindowState.height = heightInt
lastWindowState.x = xInt
lastWindowState.y = yInt
lastWindowState.valid.Store(true)
}
}
}
}
}
}
}
}
}
}
if maximized, err := db.GetSetting("window_maximized"); err == nil && maximized == "true" {
restoredMaximized = true
lastWindowState.maximized.Store(true)
}
// Determine background color based on theme setting
// Default to dark gray to prevent white flash on startup/close
// This matches the CSS dark mode background color (#1e1e1e = rgb(30, 30, 30))
backgroundColour := application.NewRGB(30, 30, 30)
if theme, err := db.GetSetting("theme"); err == nil {
if theme == "light" {
// Use white for light theme
backgroundColour = application.NewRGB(255, 255, 255)
}
// For "dark" or "auto", use dark background
}
// Create main window options
windowOptions := application.WebviewWindowOptions{
Name: "MrRSS-main-window",
Title: "MrRSS",
Width: windowWidth,
Height: windowHeight,
URL: "/",
Mac: application.MacWindow{},
Windows: application.WindowsWindow{},
Linux: application.LinuxWindow{},
BackgroundColour: backgroundColour,
}
// Set position if restored from DB
if restoredFromDB {
windowOptions.X = windowX
windowOptions.Y = windowY
}
// Create main window
mainWindow = app.Window.NewWithOptions(windowOptions)
if !restoredFromDB {
mainWindow.Center()
}
if restoredMaximized {
mainWindow.Maximise()
}
// Helper function to store window state
storeWindowState := func() {
if mainWindow == nil {
return
}
w, h := mainWindow.Size()
x, y := mainWindow.Position()
lastWindowState.maximized.Store(mainWindow.IsMaximised())
// Only store state if it's valid (reasonable size and position)
if w >= 400 && h >= 300 && w <= 4000 && h <= 3000 {
if x > -1000 && x < 3000 && y > -1000 && y < 3000 {
lastWindowState.width = w
lastWindowState.height = h
lastWindowState.x = x
lastWindowState.y = y
lastWindowState.valid.Store(true)
}
}
}
// Create system tray if close_to_tray is enabled
var systemTray *application.SystemTray
setupSystemTray := func() {
if systemTray != nil {
return // Already set up
}
systemTray = app.SystemTray.New()
systemTray.SetIcon(getAppIcon())
// Create tray menu
trayMenu := app.NewMenu()
// Get language for labels
lang := "en"
if l, err := db.GetSetting("language"); err == nil && l != "" {
lang = l
}
var showLabel, refreshLabel, quitLabel string
switch lang {
case "zh-CN", "zh", "zh-cn":
showLabel = "显示 MrRSS"
refreshLabel = "立即刷新"
quitLabel = "退出"
default:
showLabel = "Show MrRSS"
refreshLabel = "Refresh now"
quitLabel = "Quit"
}
trayMenu.Add(showLabel).OnClick(func(ctx *application.Context) {
if mainWindow != nil {
// Restore window state if it was stored
if lastWindowState.valid.Load() {
width := lastWindowState.width
height := lastWindowState.height
x := lastWindowState.x
y := lastWindowState.y
if width < 400 {
width = 1024
}
if height < 300 {
height = 768
}
if x < -1000 || x > 3000 {
x = 100
}
if y < -1000 || y > 3000 {
y = 100
}
mainWindow.SetSize(width, height)
mainWindow.SetPosition(x, y)
}
mainWindow.Show()
if lastWindowState.maximized.Load() {
mainWindow.Maximise()
} else {
mainWindow.Restore()
}
}
})
trayMenu.Add(refreshLabel).OnClick(func(ctx *application.Context) {
if h.Fetcher != nil {
go h.Fetcher.FetchAll(bgCtx)
}
})
trayMenu.AddSeparator()
trayMenu.Add(quitLabel).OnClick(func(ctx *application.Context) {
quitRequested.Store(true)
app.Quit()
})
systemTray.SetMenu(trayMenu)
// Handle clicks on tray icon to show window
systemTray.OnClick(func() {
if mainWindow != nil {
mainWindow.Show()
if lastWindowState.maximized.Load() {
mainWindow.Maximise()
} else {
mainWindow.Restore()
}
}
})
}
// Track last window close attempt to handle macOS fullscreen properly
var lastCloseAttempt atomic.Int64
// Register hook for window closing event
mainWindow.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if quitRequested.Load() {
return // Allow close
}
if shouldCloseToTray() {
// On macOS, handle fullscreen exit gracefully
if runtime.GOOS == "darwin" {
now := time.Now().UnixMilli()
last := lastCloseAttempt.Load()
// If last close was within 500ms, user clicked close twice quickly
// This means fullscreen exit completed, proceed with hiding
if last > 0 && (now-last) < 500 {
lastCloseAttempt.Store(0) // Reset
storeWindowState()
setupSystemTray()
mainWindow.Hide()
e.Cancel()
return
}
// First close attempt - try to exit fullscreen
lastCloseAttempt.Store(now)
mainWindow.Restore()
// Cancel this close event
// If window was fullscreen, user needs to click close again
// If not fullscreen, Restore() does nothing and next close will proceed
e.Cancel()
return
}
// Non-macOS platforms: directly hide to tray
storeWindowState()
setupSystemTray()
mainWindow.Hide()
e.Cancel()
}
})
// Register move and resize handlers to save window state
mainWindow.RegisterHook(events.Common.WindowDidMove, func(e *application.WindowEvent) {
storeWindowState()
})
mainWindow.RegisterHook(events.Common.WindowDidResize, func(e *application.WindowEvent) {
storeWindowState()
})
mainWindow.RegisterHook(events.Common.WindowMaximise, func(e *application.WindowEvent) {
lastWindowState.maximized.Store(true)
if err := db.SetSetting("window_maximized", "true"); err != nil {
log.Printf("Failed to save maximized window state: %v", err)
}
})
mainWindow.RegisterHook(events.Common.WindowUnMaximise, func(e *application.WindowEvent) {
lastWindowState.maximized.Store(false)
if err := db.SetSetting("window_maximized", "false"); err != nil {
log.Printf("Failed to save unmaximized window state: %v", err)
}
storeWindowState()
})
// Setup tray on startup if close_to_tray is enabled
if shouldCloseToTray() {
setupSystemTray()
}
// On macOS, handle dock icon click to show the window
if runtime.GOOS == "darwin" {
app.Event.OnApplicationEvent(events.Mac.ApplicationShouldHandleReopen, func(event *application.ApplicationEvent) {
log.Println("Dock icon clicked, showing window")
if mainWindow != nil {
mainWindow.Show()
if lastWindowState.maximized.Load() {
mainWindow.Maximise()
} else {
mainWindow.Restore()
}
}
})
}
// Detect network speed on startup in background
go func() {
time.Sleep(2 * time.Second) // Small delay to allow app to start
log.Println("Detecting network speed...")
// Get proxy settings
proxyEnabled, _ := db.GetSetting("proxy_enabled")
proxyType, _ := db.GetSetting("proxy_type")
proxyHost, _ := db.GetSetting("proxy_host")
proxyPort, _ := db.GetSetting("proxy_port")
proxyUsername, _ := db.GetSetting("proxy_username")
proxyPassword, _ := db.GetSetting("proxy_password")
// Create HTTP client with proxy if enabled
var httpClient *http.Client
if proxyEnabled == "true" {
proxyURL := httputil.BuildProxyURL(proxyType, proxyHost, proxyPort, proxyUsername, proxyPassword)
if proxyURL != "" {
client, err := httputil.CreateHTTPClient(proxyURL, 10*time.Second)
if err != nil {
log.Printf("Failed to create HTTP client with proxy: %v", err)
// Fall back to default client
httpClient = &http.Client{Timeout: 10 * time.Second}
} else {
httpClient = client
}
} else {
httpClient = &http.Client{Timeout: 10 * time.Second}
}
} else {
httpClient = &http.Client{Timeout: 10 * time.Second}
}
detector := network.NewDetector(httpClient)
detectCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result := detector.DetectSpeed(detectCtx)
if result.DetectionSuccess {
db.SetSetting("network_speed", string(result.SpeedLevel))
db.SetSetting("network_bandwidth_mbps", fmt.Sprintf("%.2f", result.BandwidthMbps))
db.SetSetting("network_latency_ms", strconv.FormatInt(result.LatencyMs, 10))
db.SetSetting("max_concurrent_refreshes", strconv.Itoa(result.MaxConcurrency))
db.SetSetting("last_network_test", result.DetectionTime.Format(time.RFC3339))
log.Printf("Network detection complete: %s (max concurrency: %d)", result.SpeedLevel, result.MaxConcurrency)
} else {
log.Printf("Network detection failed: %s", result.ErrorMessage)
}
}()
// Start background scheduler after a delay to allow UI to show first
go func() {
time.Sleep(5 * time.Second)
log.Println("Starting background scheduler...")
h.StartBackgroundScheduler(bgCtx)
}()
// Report app startup to analytics (non-blocking)
go func() {
time.Sleep(2 * time.Second) // Small delay to ensure app starts smoothly
monitorClient := monitor.NewMonitorClient("https://cf-monitor-api.ch3nyang.workers.dev", "mrrss")
_ = monitorClient.ReportAppStart(context.Background())
}()
log.Println("Window initialized, running app...")
// Run the application
err = app.Run()
// Cleanup when app exits
log.Println("Shutting down...")
// Stop background tasks first
bgCancel()
// Give some time for tasks to finish
time.Sleep(500 * time.Millisecond)
// Close DB with timeout
done := make(chan struct{})
go func() {
if err := db.Close(); err != nil {
log.Printf("Error closing database: %v", err)
}
close(done)
}()
select {
case <-done:
log.Println("Database closed")
case <-time.After(2 * time.Second):
log.Println("Database close timed out")
}
if err != nil {
log.Printf("Error running Wails: %v", err)
log.Fatal(err)
}
log.Println("Application finished")
}