diff --git a/.gitignore b/.gitignore
index 7ecceae..1858921 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,56 @@
-# Created by https://www.toptal.com/developers/gitignore/api/go,git,vim,goland,visualstudiocode
-# Edit at https://www.toptal.com/developers/gitignore?templates=go,git,vim,goland,visualstudiocode
+# Created by https://www.toptal.com/developers/gitignore/api/go,git,vim,goland,visualstudiocode,windows,macos,emacs,opencv
+# Edit at https://www.toptal.com/developers/gitignore?templates=go,git,vim,goland,visualstudiocode,windows,macos,emacs,opencv
+
+### Emacs ###
+# -*- mode: gitignore; -*-
+*~
+\#*\#
+/.emacs.desktop
+/.emacs.desktop.lock
+*.elc
+auto-save-list
+tramp
+.\#*
+
+# Org-mode
+.org-id-locations
+*_archive
+
+# flymake-mode
+*_flymake.*
+
+# eshell files
+/eshell/history
+/eshell/lastdir
+
+# elpa packages
+/elpa/
+
+# reftex files
+*.rel
+
+# AUCTeX auto folder
+/auto/
+
+# cask packages
+.cask/
+dist/
+
+# Flycheck
+flycheck_*.el
+
+# server auth directory
+/server/
+
+# projectiles files
+.projectile
+
+# directory configuration
+.dir-locals.el
+
+# network security
+/network-security.data
+
### Git ###
# Created by git for backups. To disable backups in Git:
@@ -152,6 +203,47 @@ fabric.properties
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
.idea/**/azureSettings.xml
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### OpenCV ###
+#OpenCV for Mac and Linux
+#build and release folders
+*/CMakeFiles
+*/CMakeCache.txt
+*/Makefile
+*/cmake_install.cmake
+
### Vim ###
# Swap
[._]*.s[a-v][a-z]
@@ -167,7 +259,6 @@ Sessionx.vim
# Temporary
.netrwhist
-*~
# Auto-generated tag files
tags
# Persistent undo
@@ -192,6 +283,32 @@ tags
.history
.ionide
-# End of https://www.toptal.com/developers/gitignore/api/go,git,vim,goland,visualstudiocode
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# End of https://www.toptal.com/developers/gitignore/api/go,git,vim,goland,visualstudiocode,windows,macos,emacs,opencv
bin/
diff --git a/Makefile b/Makefile
index ee2c019..a791eda 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,10 @@
APP_NAME := autoattendance
-SRC_DIR := src
+SRC_DIR := ./
BUILD_DIR := bin
CMD_PATH := ./cmd
GOCMD := go
-GOBUILD := $(GOCMD) build
+GOBUILD := $(GOCMD) build -v
GOTEST := $(GOCMD) test
GOCLEAN := $(GOCMD) clean
GOTIDY := $(GOCMD) mod tidy
@@ -28,11 +28,11 @@ $(shell $(MKDIR) $(BUILD_DIR))
all: tidy test build
build:
- @cd $(SRC_DIR) && $(GOBUILD) -o ../$(BINARY_NAME) $(CMD_PATH)
+ @cd $(SRC_DIR) && $(GOBUILD) -o $(BINARY_NAME) $(CMD_PATH)
@echo "Build complete: $(BINARY_NAME)"
run: build
- @./$(BINARY_NAME)
+ @$(BINARY_NAME)
test:
@cd $(SRC_DIR) && $(GOTEST) ./...
@@ -42,6 +42,7 @@ tidy:
clean:
@$(RM) $(BINARY_NAME)
+ @go clean -modcache
@echo "Clean complete"
help:
@@ -50,11 +51,12 @@ help:
build-linux:
@$(MKDIR) $(BUILD_DIR)
- @cd $(SRC_DIR) && GOOS=linux GOARCH=amd64 $(GOBUILD) -o ../$(BUILD_DIR)/$(APP_NAME)-linux $(CMD_PATH)
+ @cd $(SRC_DIR) && GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(APP_NAME)-linux $(CMD_PATH)
build-windows:
@$(MKDIR) $(BUILD_DIR)
- @cd $(SRC_DIR) && GOOS=windows GOARCH=amd64 $(GOBUILD) -o ../$(BUILD_DIR)/$(APP_NAME).exe $(CMD_PATH)
+ @cd $(SRC_DIR) && GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(APP_NAME).exe $(CMD_PATH)
-build-all: build-linux build-windows
- @echo "Cross-compilation complete"
+build-darwin:
+ @$(MKDIR) $(BUILD_DIR)
+ @cd $(SRC_DIR) && GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(APP_NAME)-darwin $(CMD_PATH)
diff --git a/README.md b/README.md
index b6690ad..9666a5b 100644
--- a/README.md
+++ b/README.md
@@ -54,10 +54,12 @@
### π§© ΠΠ°Π²ΠΈΡΠΈΠΌΠΎΡΡΠΈ
- git
+- make
- curl
-- go 1.25.4 (ΡΠ°Π½Π½ΠΈΠ΅ Π²Π΅ΡΡΠΈΠΈ Π½Π΅ ΡΠ΅ΡΡΠΈΡΠΎΠ²Π°Π»ΠΈΡΡ)
+- opencv 4.12.0
+- go 1.25.4
-### Linux & Windows
+### Linux & MacOS & Windows
Π‘ΠΊΠ»ΠΎΠ½ΠΈΡΡΠΉΡΠ΅ ΠΌΠΎΠΉ ΡΠ΅ΠΏΠΎΠ·ΠΈΡΠΎΡΠΈΠΉ:
```bash
@@ -74,18 +76,12 @@ cd auto-attendance
# Π΄Π»Ρ ΡΠ΅ΠΊΡΡΠ΅ΠΉ ΠΏΠ»Π°ΡΡΠΎΡΠΌΡ
make
-# Π΄Π»Ρ Linux
-make build-linux
-
-# Π΄Π»Ρ Windows
-make build-windows
+# make build-linux
+# make build-macos
+# make build-windows
```
ΠΠ°Π»Π΅Π΅ Π΄Π»Ρ Π·Π°ΠΏΡΡΠΊΠ° ΠΌΠΎΠΆΠ΅ΡΠ΅ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡ ΠΊΠΎΠΌΠ°Π½Π΄Ρ `make run`.
-### Mac
-
-Π― Π½Π΅ ΡΠ°ΡΡ Π·Π° ΡΡΠΎΡ ΠΠ°Ρ ΠΌΠ°ΠΊ. ΠΡΠ»ΠΈ Π΅ΡΡΡ ΠΊΡΠΎ-ΡΠΎ, ΠΊΡΠΎ Ρ
ΠΎΡΠ΅Ρ ΠΏΠΎΠΌΠΎΡΡ, - Ρ Π³ΠΎΡΠΎΠ² ΠΏΡΠΈΠ½ΡΡΡ pull-request ([ΠΏΠΈΡΠΈΡΠ΅ Π² ΡΠ΅Π»Π΅Π³Ρ](https://t.me/thebreadcat)).
-
# ΠΠΎΠ΄Π΄Π΅ΡΠΆΠ°ΡΡ ΡΠ°Π·ΡΠ°Π±ΠΎΡΡΠΈΠΊΠΎΠ²

diff --git a/src/cmd/main.go b/cmd/main.go
similarity index 74%
rename from src/cmd/main.go
rename to cmd/main.go
index 1bf2699..bd8cbdc 100644
--- a/src/cmd/main.go
+++ b/cmd/main.go
@@ -20,9 +20,13 @@
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/screencapturer"
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/vision"
"go.uber.org/fx"
)
@@ -32,9 +36,14 @@ func main() {
config.NewConfig,
logger.NewLogger,
linkvalidator.NewLinkValidator,
+ browserlauncher.NewBrowserLauncher,
+ screencapturer.NewWholeScreenCapturer,
+ vision.NewVision,
+ app.NewApp,
),
- fx.Invoke(func(log *logger.Logger) {
- log.Info("Starting application...");
+ fx.Invoke(func(a app.App) {
+ a.Init()
+ a.Start()
}),
)
diff --git a/src/go.mod b/go.mod
similarity index 62%
rename from src/go.mod
rename to go.mod
index ec0d5e2..58b8632 100644
--- a/src/go.mod
+++ b/go.mod
@@ -2,11 +2,21 @@ module git.weirdcat.su/weirdcat/auto-attendance
go 1.25.4
-require github.com/spf13/viper v1.21.0
+require (
+ github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
+ github.com/makiuchi-d/gozxing v0.1.1
+ github.com/spf13/viper v1.21.0
+ go.uber.org/fx v1.24.0
+ gocv.io/x/gocv v0.42.0
+)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/gen2brain/shm v0.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/jezek/xgb v1.1.1 // indirect
+ github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
@@ -15,10 +25,10 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/dig v1.19.0 // indirect
- go.uber.org/fx v1.24.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
+ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..5d866fb
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,74 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
+github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
+github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
+github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWrS+E/7xMVpydsI48PMHcc7SfR4OxkDF4=
+github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
+github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
+github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
+github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
+github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
+go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
+go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
+go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
+go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
+go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
+go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
+go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
+go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+gocv.io/x/gocv v0.42.0 h1:AAsrFJH2aIsQHukkCovWqj0MCGZleQpVyf5gNVRXjQI=
+gocv.io/x/gocv v0.42.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
+golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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/src/internal/browserlauncher/chrome.go b/internal/app/stats.go
similarity index 84%
rename from src/internal/browserlauncher/chrome.go
rename to internal/app/stats.go
index fdadbe0..baa62ba 100644
--- a/src/internal/browserlauncher/chrome.go
+++ b/internal/app/stats.go
@@ -1,7 +1,7 @@
// 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
+// 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
@@ -17,4 +17,13 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-package browserlauncher
+package app
+
+import "time"
+
+type Stats struct {
+ Status QrminatorStatus
+ Uptime time.Duration
+ FoundAmount int
+ Link string
+}
diff --git a/src/internal/browserlauncher/firefox.go b/internal/app/status.go
similarity index 84%
rename from src/internal/browserlauncher/firefox.go
rename to internal/app/status.go
index fdadbe0..6def329 100644
--- a/src/internal/browserlauncher/firefox.go
+++ b/internal/app/status.go
@@ -1,7 +1,7 @@
// 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
+// 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
@@ -17,4 +17,15 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-package browserlauncher
+package app
+
+type QrminatorStatus int16
+
+const (
+ Offline QrminatorStatus = iota
+ Waiting
+ Screenshotting
+ Analyzing
+ Validating
+ Completed
+)
diff --git a/internal/browserlauncher/browserlauncher.go b/internal/browserlauncher/browserlauncher.go
new file mode 100644
index 0000000..641060f
--- /dev/null
+++ b/internal/browserlauncher/browserlauncher.go
@@ -0,0 +1,75 @@
+package browserlauncher
+
+import (
+ "fmt"
+ "os/exec"
+ "runtime"
+
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/config"
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/logger"
+)
+
+type BrowserLauncher interface {
+ OpenDefault(url string) error
+ OpenCustom(url string) error
+ OpenAuto(url string) error
+}
+
+type browserLauncherImpl struct {
+ config *config.Config
+ log *logger.Logger
+ useCustomCommand bool
+ customCommand string
+}
+
+// OpenAuto implements BrowserLauncher.
+func (b *browserLauncherImpl) OpenAuto(url string) error {
+ if b.useCustomCommand {
+ return b.OpenCustom(url)
+ } else {
+ return b.OpenDefault(url)
+ }
+}
+
+// OpenCustom implements BrowserLauncher.
+func (b *browserLauncherImpl) OpenCustom(url string) error {
+ command := fmt.Sprintf(b.customCommand, url)
+
+ b.log.Debug("opening link with custom command", "command", command, "url", url)
+ err := exec.Command(command).Start()
+
+ if err != nil {
+ b.log.Error("failed to open link with custom command", "command", command, "url", url, "error", err)
+ }
+
+ return err
+}
+
+// OpenDefault implements BrowserLauncher.
+func (b *browserLauncherImpl) OpenDefault(url string) (err error) {
+ b.log.Debug("opening link with default browser", "url", url)
+ switch runtime.GOOS {
+ case "windows":
+ err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
+ default:
+ err = exec.Command("xdg-open", url).Start()
+ }
+
+ if err != nil {
+ b.log.Error("failed to open link with default browser", "url", url, "error", err)
+ }
+
+ return err
+}
+
+func NewBrowserLauncher(config *config.Config, log *logger.Logger) BrowserLauncher {
+
+ useCustomCommand := config.App.UseCustomBrowserCommand
+ customCommand := config.App.BrowserOpenCommand
+ return &browserLauncherImpl{
+ config: config,
+ log: log,
+ useCustomCommand: useCustomCommand,
+ customCommand: customCommand,
+ }
+}
diff --git a/src/internal/config/config.go b/internal/config/config.go
similarity index 60%
rename from src/internal/config/config.go
rename to internal/config/config.go
index 8a3638f..a771cf0 100644
--- a/src/internal/config/config.go
+++ b/internal/config/config.go
@@ -30,6 +30,7 @@ import (
type Config struct {
App AppConfig `mapstructure:"app"`
+ Screenshot ScreenshotConfig `mapstructure:"screenshot"`
Communication CommunicationConfig `mapstructure:"communication"`
Telemetry TelemetryConfig `mapstructure:"telemetry"`
Logging LoggingConfig `mapstructure:"logging"`
@@ -39,11 +40,20 @@ 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"`
- Browser string `mapstructure:"browser"`
+ UseCustomBrowserCommand bool `mapstructure:"use_custom_browser_command"`
+ BrowserOpenCommand string `mapstructure:"browser_open_command"`
EnableCheckingUpdates bool `mapstructure:"enable_checking_updates"`
}
+type ScreenshotConfig struct {
+ ScreenIndex int `mapstructure:"screen_index"`
+ Interval int `mapstructure:"interval"`
+ Directory string `mapstructure:"directory"`
+ BufferCount int `mapstructure:"buffer_count"`
+}
+
type CommunicationConfig struct {
QrUrl string `mapstructure:"self_approve_url"`
QrQueryToken string `mapstructure:"qr_query_token"`
@@ -60,18 +70,35 @@ type LoggingConfig struct {
Output string `mapstructure:"output"`
}
+func getTempDirectoryPath() string {
+ return os.TempDir()
+}
+
func getDefaultConfig() Config {
return Config{
App: AppConfig{
SettingsReviewed: false,
EnableAlarm: false,
EnableLinkOpening: true,
+ LinksToOpenCount: 5,
UseAttendanceJounralApi: false,
- Browser: "firefox",
+ UseCustomBrowserCommand: false,
+ BrowserOpenCommand: "firefox %s",
EnableCheckingUpdates: true,
},
+ Screenshot: ScreenshotConfig{
+ ScreenIndex: 0,
+ Interval: 2,
+ Directory: getTempDirectoryPath(),
+ BufferCount: 5,
+ },
+ Communication: CommunicationConfig{
+ QrUrl: "",
+ QrQueryToken: "",
+ ApiSelfApproveMethod: "",
+ },
Logging: LoggingConfig{
- Level: "info",
+ Level: "debug",
Output: "stdout",
},
Telemetry: TelemetryConfig{
@@ -113,10 +140,22 @@ 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.browser", defaults.App.Browser)
+ v.SetDefault("app.use_custom_browser_command", defaults.App.UseCustomBrowserCommand)
+ v.SetDefault("app.browser_open_command", defaults.App.BrowserOpenCommand)
v.SetDefault("app.enable_checking_updates", defaults.App.EnableCheckingUpdates)
+ v.SetDefault("screenshot.screen_index", defaults.Screenshot.ScreenIndex)
+ v.SetDefault("screenshot.interval", defaults.Screenshot.Interval)
+ 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)
@@ -127,35 +166,37 @@ func initializeViper(appName string) (*viper.Viper, string, error) {
}
func (c *Config) Save() error {
- v, _, err := initializeViper(constants.AppName)
- if err != nil {
- return fmt.Errorf("failed to initialize viper: %w", err)
- }
+ v, _, err := initializeViper(constants.AppName)
+ if err != nil {
+ return fmt.Errorf("failed to initialize viper: %w", err)
+ }
- 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.use_attendance_journal_api", c.App.UseAttendanceJounralApi)
- v.Set("app.browser", c.App.Browser)
- v.Set("app.enable_checking_updates", c.App.EnableCheckingUpdates)
+ 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)
+ v.Set("app.enable_checking_updates", c.App.EnableCheckingUpdates)
- v.Set("logging.level", c.Logging.Level)
- v.Set("logging.output", c.Logging.Output)
+ v.Set("screenshot.screen_index", c.Screenshot.ScreenIndex)
+ v.Set("screenshot.interval", c.Screenshot.Interval)
+ v.Set("screenshot.directory", c.Screenshot.Directory)
+ v.Set("screenshot.buffer_count", c.Screenshot.BufferCount)
- v.Set("telemetry.enable_statistics_collection", c.Telemetry.EnableStatisticsCollection)
- v.Set("telemetry.enable_anonymous_error_reports", c.Telemetry.EnableAnonymousErrorReports)
+ // 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)
- 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)
- }
+ v.Set("logging.level", c.Logging.Level)
+ v.Set("logging.output", c.Logging.Output)
- return v.WriteConfig()
+ v.Set("telemetry.enable_statistics_collection", c.Telemetry.EnableStatisticsCollection)
+ v.Set("telemetry.enable_anonymous_error_reports", c.Telemetry.EnableAnonymousErrorReports)
+
+ return v.WriteConfig()
}
func NewConfig() (*Config, error) {
diff --git a/src/internal/constants/app.go b/internal/constants/app.go
similarity index 91%
rename from src/internal/constants/app.go
rename to internal/constants/app.go
index 4e6eb7e..d41119d 100644
--- a/src/internal/constants/app.go
+++ b/internal/constants/app.go
@@ -20,5 +20,6 @@
package constants
const (
- AppName = "auto-attendance"
+ AppName = "auto-attendance"
+ AppClassGtk = "su.weirdcat.autoattendance"
)
diff --git a/src/internal/linkvalidator/linkvalidator.go b/internal/linkvalidator/linkvalidator.go
similarity index 63%
rename from src/internal/linkvalidator/linkvalidator.go
rename to internal/linkvalidator/linkvalidator.go
index b4e8173..70db3ac 100644
--- a/src/internal/linkvalidator/linkvalidator.go
+++ b/internal/linkvalidator/linkvalidator.go
@@ -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
+ config *config.Config
+ log *logger.Logger
+ seenMu sync.RWMutex
+ seenLinks map[string]bool
}
// ValidateLink implements LinkValidator.
@@ -52,10 +57,10 @@ func (v *linkValidatorImpl) ValidateLink(rawURL string) (token string, ok bool)
if v.config.Communication.QrUrl != "" {
expectedURL, err := url.Parse(v.config.Communication.QrUrl)
if err == nil {
- if parsedURL.Scheme != expectedURL.Scheme ||
- parsedURL.Host != expectedURL.Host ||
- parsedURL.Path != expectedURL.Path {
- v.log.Debug("URL doesn't match configured QR URL pattern",
+ if parsedURL.Scheme != expectedURL.Scheme ||
+ parsedURL.Host != expectedURL.Host ||
+ parsedURL.Path != expectedURL.Path {
+ v.log.Debug("URL doesn't match configured QR URL pattern",
"url", rawURL, "expected", v.config.Communication.QrUrl)
return "", false
}
@@ -64,7 +69,7 @@ func (v *linkValidatorImpl) ValidateLink(rawURL string) (token string, ok bool)
if v.config.Communication.ApiSelfApproveMethod != "" {
if !strings.Contains(parsedURL.Path, v.config.Communication.ApiSelfApproveMethod) {
- v.log.Debug("URL doesn't contain expected API method",
+ v.log.Debug("URL doesn't contain expected API method",
"url", parsedURL.Path, "expected", v.config.Communication.ApiSelfApproveMethod)
return "", false
}
@@ -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),
}
}
diff --git a/src/internal/logger/logger.go b/internal/logger/logger.go
similarity index 100%
rename from src/internal/logger/logger.go
rename to internal/logger/logger.go
diff --git a/src/internal/screenshotter/screenshotter.go b/internal/screencapturer/interface.go
similarity index 88%
rename from src/internal/screenshotter/screenshotter.go
rename to internal/screencapturer/interface.go
index 87cf36e..a0fb940 100644
--- a/src/internal/screenshotter/screenshotter.go
+++ b/internal/screencapturer/interface.go
@@ -1,7 +1,7 @@
// 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
+// 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
@@ -17,4 +17,9 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-package screenshotter
+package screencapturer
+
+type ScreenCapturer interface {
+ Init() error
+ Get() (filepath string, err error)
+}
diff --git a/internal/screencapturer/wholescreencapturer.go b/internal/screencapturer/wholescreencapturer.go
new file mode 100644
index 0000000..dbb2398
--- /dev/null
+++ b/internal/screencapturer/wholescreencapturer.go
@@ -0,0 +1,186 @@
+// 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 screencapturer
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "image"
+ "image/png"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/config"
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/constants"
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/logger"
+ "github.com/kbinani/screenshot"
+ "go.uber.org/fx"
+)
+
+var (
+ ErrNotInitialized = errors.New("wholescreencapturer was not initialized")
+)
+
+type wholeScreenCapturer struct {
+ config *config.Config
+ log *logger.Logger
+
+ displayIndex int
+ displayBounds image.Rectangle
+ bufferCount int
+ tempDirectory string
+ initialized bool
+
+ files []string
+ mu sync.RWMutex
+}
+
+// Get implements ScreenCapturer.
+func (w *wholeScreenCapturer) Get() (filepath string, err error) {
+ if !w.initialized {
+ return "", ErrNotInitialized
+ }
+
+ fp, err := w.captureAndSave()
+ if err != nil {
+ w.log.Error("screenshot capture failed", "error", err)
+ return "", err
+ }
+
+ w.addToBuffer(fp)
+ return fp, nil
+}
+
+// Init implements ScreenCapturer.
+func (w *wholeScreenCapturer) Init() (err error) {
+ if w.initialized {
+ w.log.Debug("wholescreencapturer already initialized, skipping initialization")
+ return nil
+ }
+
+ displayCount := screenshot.NumActiveDisplays()
+ w.displayIndex = w.config.Screenshot.ScreenIndex % displayCount
+ w.log.Debug("detected displays", "count", displayCount, "selected", w.displayIndex)
+
+ w.displayBounds = screenshot.GetDisplayBounds(w.displayIndex)
+ w.log.Debug(
+ "display bounds set",
+ "dx", w.displayBounds.Dx(),
+ "dy", w.displayBounds.Dy(),
+ )
+
+ w.bufferCount = w.config.Screenshot.BufferCount
+ w.log.Debug("screenshot buffer count set", "count", w.bufferCount)
+
+ if w.tempDirectory, err = w.createTempDirectory(); err != nil {
+ w.log.Error("failed to create temporary directory", "error", err)
+ return err
+ }
+ w.log.Debug("temporary directory created", "path", w.tempDirectory)
+
+ w.files = make([]string, 0, w.bufferCount)
+ w.initialized = true
+ return nil
+}
+
+func (w *wholeScreenCapturer) captureAndSave() (string, error) {
+ img, err := screenshot.CaptureRect(w.displayBounds)
+ if err != nil {
+ w.log.Error("failed to capture screenshot", "error", err)
+ return "", err
+ }
+
+ now := time.Now().UnixMilli()
+ filename := fmt.Sprintf("%d.png", now)
+ filePath := filepath.Join(w.tempDirectory, filename)
+
+ file, err := os.Create(filePath)
+ if err != nil {
+ w.log.Error("failed to create screenshot file", "path", filePath, "error", err)
+ return "", err
+ }
+
+ defer file.Close()
+ err = png.Encode(file, img)
+ if err != nil {
+ w.log.Error("failed to encode image into file", "path", filePath, "error", err)
+ return "", err
+ }
+
+ w.log.Debug("Screenshot saved", "path", filePath)
+ return filePath, nil
+}
+
+func (w *wholeScreenCapturer) addToBuffer(fp string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.files = append(w.files, fp)
+
+ if len(w.files) > w.bufferCount {
+ old := w.files[0]
+ if err := os.Remove(old); err != nil {
+ w.log.Warn("failed to remove old screenshot", "path", old, "error", err)
+ } else {
+ w.log.Debug("removed old screenshot", "path", old)
+ }
+ w.files = w.files[1:]
+ }
+}
+
+func (w *wholeScreenCapturer) createTempDirectory() (path string, err error) {
+ return os.MkdirTemp(w.config.Screenshot.Directory, constants.AppName+"-*")
+}
+
+func NewWholeScreenCapturer(
+ lc fx.Lifecycle, config *config.Config, log *logger.Logger,
+) ScreenCapturer {
+ capturer := &wholeScreenCapturer{
+ config: config,
+ log: log,
+ }
+ lc.Append(fx.StopHook(func(ctx context.Context) error {
+ if !capturer.initialized {
+ log.Debug("wholescreencapturer not initialized, nothing to do")
+ return nil
+ }
+
+ log.Debug("cleaning up wholescreencapturer")
+
+ // Clean up all screenshot files
+ capturer.mu.Lock()
+ defer capturer.mu.Unlock()
+ for _, file := range capturer.files {
+ if err := os.Remove(file); err != nil {
+ log.Warn("failed to remove screenshot file during cleanup", "path", file, "error", err)
+ }
+ }
+
+ err := os.RemoveAll(capturer.tempDirectory)
+ if err != nil {
+ log.Error("failed to remove temp directory")
+ return err
+ }
+ return nil
+ }))
+ return capturer
+}
diff --git a/internal/vision/vision.go b/internal/vision/vision.go
new file mode 100644
index 0000000..f1f2ba1
--- /dev/null
+++ b/internal/vision/vision.go
@@ -0,0 +1,89 @@
+// 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 vision
+
+import (
+ "errors"
+
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/logger"
+ "github.com/makiuchi-d/gozxing"
+ "github.com/makiuchi-d/gozxing/qrcode"
+ "gocv.io/x/gocv"
+)
+
+type Vision interface {
+ AnalyzeImage(filePath string) (data VisionData, err error)
+}
+
+type visionImpl struct {
+ log *logger.Logger
+}
+
+var (
+ ErrImageEmpty = errors.New("Image from file was empty")
+)
+
+// AnalyzeImage implements Vision.
+func (v *visionImpl) AnalyzeImage(filePath string) (data VisionData, err error) {
+ // TODO: scanning for multiple QR-codes at once
+ v.log.Debug("analyzing image for qr codes", "filePath", filePath)
+
+ img := gocv.IMRead(filePath, gocv.IMReadColor)
+ if img.Empty() {
+ v.log.Error("could not read image file", "filePath", filePath)
+ return VisionData{}, ErrImageEmpty
+ }
+ defer img.Close()
+
+ // Convert to grayscale for QR code detection
+ gray := gocv.NewMat()
+ defer gray.Close()
+ gocv.CvtColor(img, &gray, gocv.ColorBGRToGray)
+
+ // Convert gocv.Mat to image.Image for gozxing
+ imgGray, err := gray.ToImage()
+ if err != nil {
+ v.log.Error("failed to convert image", "error", err)
+ return VisionData{}, err
+ }
+
+ // Create a binary bitmap from the image
+ bmp, err := gozxing.NewBinaryBitmapFromImage(imgGray)
+ if err != nil {
+ v.log.Error("failed to create binary bitmap", "error", err)
+ return VisionData{}, err
+ }
+
+ reader := qrcode.NewQRCodeReader()
+ result, err := reader.Decode(bmp, nil)
+ if err != nil {
+ v.log.Debug("no qr code found in image", "filePath", filePath)
+ return VisionData{}, nil
+ }
+
+ v.log.Info("QR code decoded successfully", "content", result.GetText())
+
+ data = VisionData{result.GetText()}
+ return data, nil
+}
+
+func NewVision(log *logger.Logger) Vision {
+ return &visionImpl{log: log}
+}
diff --git a/internal/vision/visiondata.go b/internal/vision/visiondata.go
new file mode 100644
index 0000000..40f2700
--- /dev/null
+++ b/internal/vision/visiondata.go
@@ -0,0 +1,3 @@
+package vision
+
+type VisionData []string
diff --git a/src/go.sum b/src/go.sum
deleted file mode 100644
index 724bd2d..0000000
--- a/src/go.sum
+++ /dev/null
@@ -1,35 +0,0 @@
-github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
-github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
-github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
-github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
-github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
-github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
-github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
-github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
-github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
-github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
-github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
-github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
-github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
-github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
-github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
-github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
-go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
-go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
-go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
-go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
-go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
-go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
-go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
-go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
-golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/src/internal/vision/vision.go b/src/internal/vision/vision.go
deleted file mode 100644
index e4ed0da..0000000
--- a/src/internal/vision/vision.go
+++ /dev/null
@@ -1,20 +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 vision
diff --git a/src/utils/platform.go b/src/utils/platform.go
deleted file mode 100644
index 7f27a60..0000000
--- a/src/utils/platform.go
+++ /dev/null
@@ -1,20 +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 platform