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{},
- }
-}