14 Commits

11 changed files with 416 additions and 144 deletions

View File

@@ -1,7 +1,7 @@
APP_NAME := autoattendance
SRC_DIR := ./
BUILD_DIR := bin
CMD_PATH := ./cmd
CMD_PATH := ./cmd/qrminator-cli/
GOCMD := go
GOBUILD := $(GOCMD) build -v

View File

@@ -19,13 +19,15 @@
- [x] Обнаружение QR-кодов
- [x] Вывод ссылки в консоль
- [x] Автоматический переход по ссылке
- [x] Звуковой сигнал
### ⏰ Запланированные
- [ ] GUI
- [ ] Звуковой сигнал
- [ ] Push-уведомления с ntfy
### 🤔 На рассмотрении
- [ ] Работа при свёрнутом окне
- [ ] Скриншот только области окна браузера
- [ ] Работа при свёрнутом окне браузера
- [ ] Отметка через API журнала
## 📐 Реализация
@@ -38,8 +40,9 @@
Вам потребуется поддерживаемая операционная система из списка ниже. Если галочки нет, то поддержка появится в будущем.
- [x] Linux (Wayland)
- [ ] Linux (XORG)
- [x] Windows 10/11
- [x] Linux (XORG)
- [ ] Windows 10/11 (не тестировалось, должно билдиться)
- [ ] MacOS (не тестировалось, должно билдиться)
### ⬇️ Установка
@@ -47,18 +50,20 @@
### ⚙️ Настройка
При первом запуске будет необходимо выполнить настройку приложения. Для этого нужно выбрать соответствующий пункт меню и следовать инструкциям на экране. Если Вы получили готовый файл настройки из другого источника, можно передать программе его URL.
При первом запуске будет необходимо выполнить настройку приложения. В Linux файл конфигурации располагается здесь:
`~/.config/auto-attendance/auto-attendance.toml`. Необходимо получить актуальный файл конфигурации, который будет работать с конкретным журналом посещений
## 🔨 Сборка
### 🧩 Зависимости
- git
- make
- curl
- opencv >=4.12.0
- go 1.25.4 (ранние версии не тестировались)
- opencv 4.12.0
- go 1.25.4
### Linux & Windows & MacOS
### Linux & MacOS & Windows
Склонируйте мой репозиторий:
```bash
@@ -75,14 +80,9 @@ cd auto-attendance
# для текущей платформы
make
# для Linux
make build-linux
# для Windows
make build-windows
# для MacOS
make build-darwin
# make build-linux
# make build-macos
# make build-windows
```
Далее для запуска можете использовать команду `make run`.

View File

@@ -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/qrminator"
"git.weirdcat.su/weirdcat/auto-attendance/internal/screencapturer"
"git.weirdcat.su/weirdcat/auto-attendance/internal/vision"
"go.uber.org/fx"
@@ -39,10 +39,11 @@ func main() {
browserlauncher.NewBrowserLauncher,
screencapturer.NewWholeScreenCapturer,
vision.NewVision,
qrminator.NewQrminator,
app.NewApp,
),
fx.Invoke(func(qrm qrminator.Qrminator) {
fx.Invoke(func(a app.App) {
a.Init()
a.Start()
}),
)

314
internal/app/app.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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 obtain screenshot from capturer", "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
}

View File

@@ -17,7 +17,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package qrminator
package app
import "time"

View File

@@ -17,7 +17,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package qrminator
package app
type QrminatorStatus int16

View File

@@ -40,6 +40,7 @@ type AppConfig struct {
SettingsReviewed bool `mapstructure:"settings_reviewed"`
EnableAlarm bool `mapstructure:"enable_alarm"`
EnableLinkOpening bool `mapstructure:"enable_link_opening"`
LinksToOpenCount int `mapstructure:"links_to_open_count"`
UseAttendanceJounralApi bool `mapstructure:"use_attendance_journal_api"`
UseCustomBrowserCommand bool `mapstructure:"use_custom_browser_command"`
BrowserOpenCommand string `mapstructure:"browser_open_command"`
@@ -79,6 +80,7 @@ func getDefaultConfig() Config {
SettingsReviewed: false,
EnableAlarm: false,
EnableLinkOpening: true,
LinksToOpenCount: 5,
UseAttendanceJounralApi: false,
UseCustomBrowserCommand: false,
BrowserOpenCommand: "firefox %s",
@@ -86,12 +88,17 @@ func getDefaultConfig() Config {
},
Screenshot: ScreenshotConfig{
ScreenIndex: 0,
Interval: 5,
Interval: 2,
Directory: getTempDirectoryPath(),
BufferCount: 5,
},
Communication: CommunicationConfig{
QrUrl: "",
QrQueryToken: "",
ApiSelfApproveMethod: "",
},
Logging: LoggingConfig{
Level: "info",
Level: "debug",
Output: "stdout",
},
Telemetry: TelemetryConfig{
@@ -133,6 +140,7 @@ func initializeViper(appName string) (*viper.Viper, string, error) {
v.SetDefault("app.settings_reviewed", defaults.App.SettingsReviewed)
v.SetDefault("app.enable_alarm", defaults.App.EnableAlarm)
v.SetDefault("app.enable_link_opening", defaults.App.EnableLinkOpening)
v.SetDefault("app.links_to_open_count", defaults.App.LinksToOpenCount)
v.SetDefault("app.use_attendance_journal_api", defaults.App.UseAttendanceJounralApi)
v.SetDefault("app.use_custom_browser_command", defaults.App.UseCustomBrowserCommand)
v.SetDefault("app.browser_open_command", defaults.App.BrowserOpenCommand)
@@ -143,6 +151,11 @@ func initializeViper(appName string) (*viper.Viper, string, error) {
v.SetDefault("screenshot.directory", defaults.Screenshot.Directory)
v.SetDefault("screenshot.buffer_count", defaults.Screenshot.BufferCount)
// Set empty string defaults for communication parameters
v.SetDefault("communication.self_approve_url", defaults.Communication.QrUrl)
v.SetDefault("communication.qr_query_token", defaults.Communication.QrQueryToken)
v.SetDefault("communication.api_self_approve_method", defaults.Communication.ApiSelfApproveMethod)
v.SetDefault("logging.level", defaults.Logging.Level)
v.SetDefault("logging.output", defaults.Logging.Output)
@@ -161,6 +174,7 @@ func (c *Config) Save() error {
v.Set("app.settings_reviewed", c.App.SettingsReviewed)
v.Set("app.enable_alarm", c.App.EnableAlarm)
v.Set("app.enable_link_opening", c.App.EnableLinkOpening)
v.Set("app.links_to_open_count", c.App.LinksToOpenCount)
v.Set("app.use_attendance_journal_api", c.App.UseAttendanceJounralApi)
v.Set("app.use_custom_browser_command", c.App.UseCustomBrowserCommand)
v.Set("app.browser_open_command", c.App.BrowserOpenCommand)
@@ -171,22 +185,17 @@ func (c *Config) Save() error {
v.Set("screenshot.directory", c.Screenshot.Directory)
v.Set("screenshot.buffer_count", c.Screenshot.BufferCount)
// Always set communication parameters, even if empty
v.Set("communication.self_approve_url", c.Communication.QrUrl)
v.Set("communication.qr_query_token", c.Communication.QrQueryToken)
v.Set("communication.api_self_approve_method", c.Communication.ApiSelfApproveMethod)
v.Set("logging.level", c.Logging.Level)
v.Set("logging.output", c.Logging.Output)
v.Set("telemetry.enable_statistics_collection", c.Telemetry.EnableStatisticsCollection)
v.Set("telemetry.enable_anonymous_error_reports", c.Telemetry.EnableAnonymousErrorReports)
if c.Communication.QrUrl != "" {
v.Set("communication.self_approve_url", c.Communication.QrUrl)
}
if c.Communication.QrQueryToken != "" {
v.Set("communication.qr_query_token", c.Communication.QrQueryToken)
}
if c.Communication.ApiSelfApproveMethod != "" {
v.Set("communication.api_self_approve_method", c.Communication.ApiSelfApproveMethod)
}
return v.WriteConfig()
}

View File

@@ -22,6 +22,7 @@ package linkvalidator
import (
"net/url"
"strings"
"sync"
"git.weirdcat.su/weirdcat/auto-attendance/internal/config"
"git.weirdcat.su/weirdcat/auto-attendance/internal/logger"
@@ -29,11 +30,15 @@ import (
type LinkValidator interface {
ValidateLink(string) (token string, ok bool)
ResetSeenLinks()
GetSeenCount() int
}
type linkValidatorImpl struct {
config *config.Config
log *logger.Logger
seenMu sync.RWMutex
seenLinks map[string]bool
}
// ValidateLink implements LinkValidator.
@@ -76,13 +81,46 @@ func (v *linkValidatorImpl) ValidateLink(rawURL string) (token string, ok bool)
return "", false
}
v.log.Debug("URL validation successful", "url", rawURL)
// Check if we've already seen this token
v.seenMu.RLock()
alreadySeen := v.seenLinks[token]
v.seenMu.RUnlock()
if alreadySeen {
v.log.Debug("URL token already processed, skipping", "token", token, "url", rawURL)
return token, false
}
// Mark this token as seen
v.seenMu.Lock()
v.seenLinks[token] = true
v.seenMu.Unlock()
v.log.Debug("URL validation successful", "url", rawURL, "token", token)
return token, true
}
// ResetSeenLinks clears the cache of seen links
func (v *linkValidatorImpl) ResetSeenLinks() {
v.seenMu.Lock()
defer v.seenMu.Unlock()
oldCount := len(v.seenLinks)
v.seenLinks = make(map[string]bool)
v.log.Debug("Reset seen links cache", "previous_count", oldCount)
}
// GetSeenCount returns the number of unique links seen
func (v *linkValidatorImpl) GetSeenCount() int {
v.seenMu.RLock()
defer v.seenMu.RUnlock()
return len(v.seenLinks)
}
func NewLinkValidator(config *config.Config, log *logger.Logger) LinkValidator {
return &linkValidatorImpl{
config: config,
log: log,
seenLinks: make(map[string]bool),
}
}

View File

@@ -1,103 +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 <https://www.gnu.org/licenses/>.
package qrminator
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 Qrminator interface {
Init() error
Start() error
Stop() error
Toggle() error
ConsoleOutput() (string, error)
UpdateConfig(string) error
}
type qrminatorImpl 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 Qrminator.
func (q *qrminatorImpl) ConsoleOutput() (string, error) {
panic("unimplemented")
}
// Init implements Qrminator.
func (q *qrminatorImpl) Init() error {
panic("unimplemented")
}
// Start implements Qrminator.
func (q *qrminatorImpl) Start() error {
panic("unimplemented")
}
// Stop implements Qrminator.
func (q *qrminatorImpl) Stop() error {
panic("unimplemented")
}
// Toggle implements Qrminator.
func (q *qrminatorImpl) Toggle() error {
panic("unimplemented")
}
// UpdateConfig implements Qrminator.
func (q *qrminatorImpl) UpdateConfig(string) error {
panic("unimplemented")
}
func NewQrminator(
cfg *config.Config,
log *logger.Logger,
capt screencapturer.ScreenCapturer,
vis vision.Vision,
val linkvalidator.LinkValidator,
launch browserlauncher.BrowserLauncher,
) Qrminator {
return &qrminatorImpl{
config: cfg,
log: log,
capturer: capt,
vision: vis,
validator: val,
launcher: launch,
active: false,
stats: Stats{},
}
}

View File

@@ -57,7 +57,11 @@ type wholeScreenCapturer struct {
// Get implements ScreenCapturer.
func (w *wholeScreenCapturer) Get() (filepath string, err error) {
if !w.initialized {
w.mu.RLock()
initialized := w.initialized
w.mu.RUnlock()
if !initialized {
return "", ErrNotInitialized
}
@@ -73,6 +77,9 @@ func (w *wholeScreenCapturer) Get() (filepath string, err error) {
// Init implements ScreenCapturer.
func (w *wholeScreenCapturer) Init() (err error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.initialized {
w.log.Debug("wholescreencapturer already initialized, skipping initialization")
return nil
@@ -104,6 +111,7 @@ func (w *wholeScreenCapturer) Init() (err error) {
}
func (w *wholeScreenCapturer) captureAndSave() (string, error) {
w.mu.RLock()
img, err := screenshot.CaptureRect(w.displayBounds)
if err != nil {
w.log.Error("failed to capture screenshot", "error", err)
@@ -113,6 +121,7 @@ func (w *wholeScreenCapturer) captureAndSave() (string, error) {
now := time.Now().UnixMilli()
filename := fmt.Sprintf("%d.png", now)
filePath := filepath.Join(w.tempDirectory, filename)
w.mu.RUnlock()
file, err := os.Create(filePath)
if err != nil {
@@ -159,7 +168,11 @@ func NewWholeScreenCapturer(
log: log,
}
lc.Append(fx.StopHook(func(ctx context.Context) error {
if !capturer.initialized {
capturer.mu.RLock()
initialized := capturer.initialized
capturer.mu.RUnlock()
if !initialized {
log.Debug("wholescreencapturer not initialized, nothing to do")
return nil
}

View File

@@ -74,7 +74,7 @@ func (v *visionImpl) AnalyzeImage(filePath string) (data VisionData, err error)
reader := qrcode.NewQRCodeReader()
result, err := reader.Decode(bmp, nil)
if err != nil {
v.log.Debug("no qr code found in image", "filePath", filePath, "error", err)
v.log.Debug("no qr code found in image", "filePath", filePath)
return VisionData{}, nil
}