// Copyright (c) 2025 Nikolai Papin // // This file is part of the Auto Attendance app that looks for // self-attend QR-codes during lectures and opens their URLs in your // browser. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU 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 General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . package app import ( "context" "fmt" "sync" "sync/atomic" "time" "git.weirdcat.su/weirdcat/auto-attendance/internal/browserlauncher" "git.weirdcat.su/weirdcat/auto-attendance/internal/config" "git.weirdcat.su/weirdcat/auto-attendance/internal/linkvalidator" "git.weirdcat.su/weirdcat/auto-attendance/internal/logger" "git.weirdcat.su/weirdcat/auto-attendance/internal/screencapturer" "git.weirdcat.su/weirdcat/auto-attendance/internal/vision" "go.uber.org/fx" ) type App interface { Init() error Start() error Stop() error Toggle() error ConsoleOutput() (string, error) UpdateConfig(string) error } type appImpl struct { config *config.Config log *logger.Logger capturer screencapturer.ScreenCapturer vision vision.Vision validator linkvalidator.LinkValidator launcher browserlauncher.BrowserLauncher active atomic.Bool stats Stats mu sync.RWMutex ctx context.Context cancel context.CancelFunc wg sync.WaitGroup startTime time.Time foundCount int consoleBuf []string consoleMu sync.RWMutex } // ConsoleOutput implements App. func (a *appImpl) ConsoleOutput() (string, error) { a.consoleMu.RLock() defer a.consoleMu.RUnlock() if len(a.consoleBuf) == 0 { return "", nil } output := "" for _, line := range a.consoleBuf { output += line + "\n" } return output, nil } // Init implements App. func (a *appImpl) Init() error { // Initialize screen capturer if err := a.capturer.Init(); err != nil { a.log.Error("Failed to initialize screen capturer", "error", err) return err } a.ctx, a.cancel = context.WithCancel(context.Background()) a.stats.Status = Offline a.consoleBuf = make([]string, 0, 100) a.addConsoleOutput("Application initialized successfully") return nil } // Start implements App. func (a *appImpl) Start() error { a.mu.Lock() defer a.mu.Unlock() if a.active.Load() { a.log.Warn("Application is already running") return fmt.Errorf("application is already running") } a.log.Info("Starting application") a.active.Store(true) a.startTime = time.Now() a.stats.Status = Waiting a.stats.FoundAmount = 0 a.foundCount = 0 a.wg.Add(1) go a.pollingLoop() a.addConsoleOutput("Application started - QR code monitoring active") a.log.Info("Application started successfully") return nil } // Stop implements App. func (a *appImpl) Stop() error { a.mu.Lock() defer a.mu.Unlock() if !a.active.Load() { a.log.Warn("Application is not running") return fmt.Errorf("application is not running") } a.log.Info("Stopping application") a.active.Store(false) if a.cancel != nil { a.cancel() } a.wg.Wait() a.stats.Status = Offline a.stats.Uptime = time.Since(a.startTime) a.addConsoleOutput(fmt.Sprintf("Application stopped - Found %d QR codes", a.foundCount)) a.log.Info("Application stopped successfully", "found_count", a.foundCount, "uptime", a.stats.Uptime) return nil } // Toggle implements App. func (a *appImpl) Toggle() error { if a.active.Load() { return a.Stop() } else { return a.Start() } } // UpdateConfig implements App. func (a *appImpl) UpdateConfig(configStr string) error { panic("not implemented") } func (a *appImpl) pollingLoop() { defer a.wg.Done() interval := time.Duration(a.config.Screenshot.Interval) * time.Second ticker := time.NewTicker(interval) defer ticker.Stop() a.log.Info("Polling loop started", "interval", interval) for { select { case <-a.ctx.Done(): a.log.Debug("Polling loop stopped via context") return case <-ticker.C: if !a.active.Load() { continue } a.processScreenshot() // Check if we've reached the maximum number of links to open if a.foundCount >= a.config.App.LinksToOpenCount { a.log.Info("Reached maximum links to open, stopping", "max_links", a.config.App.LinksToOpenCount) a.Stop() return } } } } func (a *appImpl) processScreenshot() { a.stats.Status = Screenshotting // Capture screenshot filePath, err := a.capturer.Get() if err != nil { a.log.Error("Failed to capture screenshot", "error", err) a.addConsoleOutput("Error: Failed to capture screenshot") a.stats.Status = Waiting return } a.log.Debug("Screenshot captured", "path", filePath) // Analyze image for QR codes a.stats.Status = Analyzing visionData, err := a.vision.AnalyzeImage(filePath) if err != nil { a.log.Error("Failed to analyze image", "error", err) a.addConsoleOutput("Error: Failed to analyze screenshot for QR codes") a.stats.Status = Waiting return } if len(visionData) == 0 { a.log.Debug("No QR codes found in screenshot") a.stats.Status = Waiting return } a.log.Info("QR codes found in screenshot", "count", len(visionData)) // Process each found QR code for _, data := range visionData { if !a.active.Load() { break } a.stats.Status = Validating a.stats.Link = data token, valid := a.validator.ValidateLink(data) if !valid { a.log.Debug("QR code content is not a valid link", "data", data) continue } a.log.Info("Valid QR code found", "token", token) a.addConsoleOutput(fmt.Sprintf("Valid QR code found: %s", data)) // Open the link in browser if enabled if a.config.App.EnableLinkOpening { a.stats.Status = Completed if err := a.launcher.OpenAuto(data); err != nil { a.log.Error("Failed to open link in browser", "error", err) a.addConsoleOutput("Error: Failed to open link in browser") } else { a.log.Info("Link opened in browser successfully") a.addConsoleOutput("Link opened in browser successfully") a.foundCount++ a.stats.FoundAmount = a.foundCount } } else { a.log.Info("Link opening is disabled in config") a.addConsoleOutput("Link opening is disabled - would have opened: " + data) } break } a.stats.Status = Waiting a.stats.Uptime = time.Since(a.startTime) } func (a *appImpl) addConsoleOutput(message string) { a.consoleMu.Lock() defer a.consoleMu.Unlock() timestamp := time.Now().Format("15:04:05") formattedMessage := fmt.Sprintf("[%s] %s", timestamp, message) if len(a.consoleBuf) >= 100 { a.consoleBuf = a.consoleBuf[1:] } a.consoleBuf = append(a.consoleBuf, formattedMessage) } func NewApp( lc fx.Lifecycle, cfg *config.Config, log *logger.Logger, capt screencapturer.ScreenCapturer, vis vision.Vision, val linkvalidator.LinkValidator, launch browserlauncher.BrowserLauncher, ) App { app := &appImpl{ config: cfg, log: log, capturer: capt, vision: vis, validator: val, launcher: launch, stats: Stats{ Status: Offline, Uptime: 0, FoundAmount: 0, Link: "", }, } lc.Append(fx.Hook{ OnStop: func(ctx context.Context) error { log.Debug("stopping qrminator") return app.Stop() }, }) return app }