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)). - # ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠΎΠ² ![Николай (Telegram)](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