diff --git a/cmd/main.go b/cmd/main.go index c59ed8c..bd8cbdc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -20,11 +20,11 @@ package main import ( + "git.weirdcat.su/weirdcat/auto-attendance/internal/app" "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/app" "git.weirdcat.su/weirdcat/auto-attendance/internal/screencapturer" "git.weirdcat.su/weirdcat/auto-attendance/internal/vision" "go.uber.org/fx" @@ -41,8 +41,9 @@ func main() { vision.NewVision, app.NewApp, ), - fx.Invoke(func(qrm app.App) { - + fx.Invoke(func(a app.App) { + a.Init() + a.Start() }), ) diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..c447d9a --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,314 @@ +// 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 +} diff --git a/internal/app/qrminator.go b/internal/app/qrminator.go deleted file mode 100644 index 679bf17..0000000 --- a/internal/app/qrminator.go +++ /dev/null @@ -1,104 +0,0 @@ -// 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 ( - "sync" - - "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" -) - -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 bool - stats Stats - mu sync.Mutex -} - -// ConsoleOutput implements App. -func (a *appImpl) ConsoleOutput() (string, error) { - // TODO: Outputing stdout/logs content - panic("unimplemented") -} - -// Init implements App. -func (a *appImpl) Init() error { - panic("unimplemented") -} - -// Start implements App. -func (a *appImpl) Start() error { - panic("unimplemented") -} - -// Stop implements App. -func (a *appImpl) Stop() error { - panic("unimplemented") -} - -// Toggle implements App. -func (a *appImpl) Toggle() error { - panic("unimplemented") -} - -// UpdateConfig implements App. -func (a *appImpl) UpdateConfig(string) error { - panic("unimplemented") -} - -func NewApp( - cfg *config.Config, - log *logger.Logger, - capt screencapturer.ScreenCapturer, - vis vision.Vision, - val linkvalidator.LinkValidator, - launch browserlauncher.BrowserLauncher, -) App { - return &appImpl{ - config: cfg, - log: log, - capturer: capt, - vision: vis, - validator: val, - launcher: launch, - active: false, - stats: Stats{}, - } -}