diff --git a/Makefile b/Makefile
index 7720f00..2d54f1f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
APP_NAME := autoattendance
-SRC_DIR := src
+SRC_DIR := ./
BUILD_DIR := bin
CMD_PATH := ./cmd
@@ -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) ./...
@@ -50,8 +50,8 @@ 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)
diff --git a/cmd/main.go b/cmd/main.go
new file mode 100644
index 0000000..75773bb
--- /dev/null
+++ b/cmd/main.go
@@ -0,0 +1,46 @@
+// 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 main
+
+import (
+ "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"
+)
+
+func main() {
+ app := fx.New(
+ fx.Provide(
+ config.NewConfig,
+ logger.NewLogger,
+ linkvalidator.NewLinkValidator,
+ screencapturer.NewWholeScreenCapturer,
+ vision.NewVision,
+ ),
+ fx.Invoke(func(log *logger.Logger, capturer screencapturer.ScreenCapturer) {
+ log.Debug("starting application...")
+ }),
+ )
+
+ app.Run()
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..58b8632
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,34 @@
+module git.weirdcat.su/weirdcat/auto-attendance
+
+go 1.25.4
+
+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
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ 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/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/browserlauncher/browserlauncher.go b/internal/browserlauncher/browserlauncher.go
new file mode 100644
index 0000000..bc14a7d
--- /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/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..2cae791
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,224 @@
+// 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 config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/constants"
+ "github.com/spf13/viper"
+)
+
+type Config struct {
+ App AppConfig `mapstructure:"app"`
+ Screenshot ScreenshotConfig `mapstructure:"screenshot"`
+ Communication CommunicationConfig `mapstructure:"communication"`
+ Telemetry TelemetryConfig `mapstructure:"telemetry"`
+ Logging LoggingConfig `mapstructure:"logging"`
+}
+
+type AppConfig struct {
+ SettingsReviewed bool `mapstructure:"settings_reviewed"`
+ EnableAlarm bool `mapstructure:"enable_alarm"`
+ EnableLinkOpening bool `mapstructure:"enable_link_opening"`
+ UseAttendanceJounralApi bool `mapstructure:"use_attendance_journal_api"`
+ 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"`
+ ApiSelfApproveMethod string `mapstructure:"api_self_approve_method"`
+}
+
+type TelemetryConfig struct {
+ EnableStatisticsCollection bool `mapstructure:"enable_anonymous_statistics_collection"`
+ EnableAnonymousErrorReports bool `mapstructure:"enable_anonymous_error_reports"`
+}
+
+type LoggingConfig struct {
+ Level string `mapstructure:"level"`
+ Output string `mapstructure:"output"`
+}
+
+func getTempDirectoryPath() string {
+ return os.TempDir()
+}
+
+func getDefaultConfig() Config {
+ return Config{
+ App: AppConfig{
+ SettingsReviewed: false,
+ EnableAlarm: false,
+ EnableLinkOpening: true,
+ UseAttendanceJounralApi: false,
+ UseCustomBrowserCommand: false,
+ BrowserOpenCommand: "firefox %s",
+ EnableCheckingUpdates: true,
+ },
+ Screenshot: ScreenshotConfig{
+ ScreenIndex: 0,
+ Interval: 5,
+ Directory: getTempDirectoryPath(),
+ BufferCount: 5,
+ },
+ Logging: LoggingConfig{
+ Level: "info",
+ Output: "stdout",
+ },
+ Telemetry: TelemetryConfig{
+ EnableStatisticsCollection: true,
+ EnableAnonymousErrorReports: true,
+ },
+ }
+}
+
+func getConfigDir(appName string) (string, error) {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get user config directory: %w", err)
+ }
+
+ appConfigDir := filepath.Join(configDir, appName)
+
+ if err := os.MkdirAll(appConfigDir, 0755); err != nil {
+ return "", fmt.Errorf("failed to create config directory: %w", err)
+ }
+
+ return appConfigDir, nil
+}
+
+func initializeViper(appName string) (*viper.Viper, string, error) {
+ configDir, err := getConfigDir(appName)
+ if err != nil {
+ return nil, "", err
+ }
+
+ configFile := filepath.Join(configDir, appName+".toml")
+
+ v := viper.New()
+ v.SetConfigFile(configFile)
+ v.SetConfigType("toml")
+
+ defaults := getDefaultConfig()
+
+ 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.use_attendance_journal_api", defaults.App.UseAttendanceJounralApi)
+ v.SetDefault("app.use_custom_browser_command", defaults.App.UseCustomBrowserCommand)
+ v.SetDefault("app.browser_open_command", defaults.App.BrowserOpenCommand)
+ 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)
+
+ v.SetDefault("logging.level", defaults.Logging.Level)
+ v.SetDefault("logging.output", defaults.Logging.Output)
+
+ v.SetDefault("telemetry.enable_statistics_collection", defaults.Telemetry.EnableStatisticsCollection)
+ v.SetDefault("telemetry.enable_anonymous_error_reports", defaults.Telemetry.EnableAnonymousErrorReports)
+
+ return v, configFile, nil
+}
+
+func (c *Config) Save() error {
+ 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.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("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("logging.level", c.Logging.Level)
+ v.Set("logging.output", c.Logging.Output)
+
+ v.Set("telemetry.enable_statistics_collection", c.Telemetry.EnableStatisticsCollection)
+ v.Set("telemetry.enable_anonymous_error_reports", c.Telemetry.EnableAnonymousErrorReports)
+
+ if c.Communication.QrUrl != "" {
+ v.Set("communication.self_approve_url", c.Communication.QrUrl)
+ }
+ if c.Communication.QrQueryToken != "" {
+ v.Set("communication.qr_query_token", c.Communication.QrQueryToken)
+ }
+ if c.Communication.ApiSelfApproveMethod != "" {
+ v.Set("communication.api_self_approve_method", c.Communication.ApiSelfApproveMethod)
+ }
+
+ return v.WriteConfig()
+}
+
+func NewConfig() (*Config, error) {
+ v, configFile, err := initializeViper(constants.AppName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize viper: %w", err)
+ }
+
+ if _, err := os.Stat(configFile); os.IsNotExist(err) {
+ file, err := os.Create(configFile)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create config file: %w", err)
+ }
+ file.Close()
+
+ if err := v.WriteConfig(); err != nil {
+ return nil, fmt.Errorf("failed to write default config: %w", err)
+ }
+ fmt.Printf("Created new config file at: %s\n", configFile)
+ } else if err != nil {
+ return nil, fmt.Errorf("failed to check config file: %w", err)
+ }
+
+ if err := v.ReadInConfig(); err != nil {
+ return nil, fmt.Errorf("failed to read config file: %w", err)
+ }
+
+ var config Config
+ if err := v.Unmarshal(&config); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal config: %w", err)
+ }
+
+ return &config, nil
+}
diff --git a/internal/constants/app.go b/internal/constants/app.go
new file mode 100644
index 0000000..4e6eb7e
--- /dev/null
+++ b/internal/constants/app.go
@@ -0,0 +1,24 @@
+// 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 constants
+
+const (
+ AppName = "auto-attendance"
+)
diff --git a/internal/linkvalidator/linkvalidator.go b/internal/linkvalidator/linkvalidator.go
new file mode 100644
index 0000000..c78e105
--- /dev/null
+++ b/internal/linkvalidator/linkvalidator.go
@@ -0,0 +1,88 @@
+// 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 linkvalidator
+
+import (
+ "net/url"
+ "strings"
+
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/config"
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/logger"
+)
+
+type LinkValidator interface {
+ ValidateLink(string) (token string, ok bool)
+}
+
+type linkValidatorImpl struct {
+ config *config.Config
+ log *logger.Logger
+}
+
+// ValidateLink implements LinkValidator.
+func (v *linkValidatorImpl) ValidateLink(rawURL string) (token string, ok bool) {
+ if rawURL == "" {
+ v.log.Debug("Empty URL provided for validation")
+ return "", false
+ }
+
+ parsedURL, err := url.Parse(rawURL)
+ if err != nil {
+ v.log.Debug("Failed to parse URL", "url", rawURL, "error", err)
+ return "", false
+ }
+
+ 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",
+ "url", rawURL, "expected", v.config.Communication.QrUrl)
+ return "", false
+ }
+ }
+ }
+
+ if v.config.Communication.ApiSelfApproveMethod != "" {
+ if !strings.Contains(parsedURL.Path, v.config.Communication.ApiSelfApproveMethod) {
+ v.log.Debug("URL doesn't contain expected API method",
+ "url", parsedURL.Path, "expected", v.config.Communication.ApiSelfApproveMethod)
+ return "", false
+ }
+ }
+
+ token = parsedURL.Query().Get("token")
+ if token == "" {
+ v.log.Debug("URL missing token parameter", "url", rawURL)
+ return "", false
+ }
+
+ v.log.Debug("URL validation successful", "url", rawURL)
+ return token, true
+}
+
+func NewLinkValidator(config *config.Config, log *logger.Logger) LinkValidator {
+ return &linkValidatorImpl{
+ config: config,
+ log: log,
+ }
+}
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
new file mode 100644
index 0000000..3f2b7ea
--- /dev/null
+++ b/internal/logger/logger.go
@@ -0,0 +1,66 @@
+// 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 logger
+
+import (
+ "log/slog"
+ "os"
+
+ "git.weirdcat.su/weirdcat/auto-attendance/internal/config"
+)
+
+type Logger struct {
+ *slog.Logger
+ *config.Config
+}
+
+func NewLogger(config *config.Config) *Logger {
+ var level slog.Level
+ switch config.Logging.Level {
+ case "debug":
+ level = slog.LevelDebug
+ case "info":
+ level = slog.LevelInfo
+ case "warn":
+ level = slog.LevelWarn
+ case "error":
+ level = slog.LevelError
+ default:
+ level = slog.LevelInfo
+ }
+
+ var output *os.File
+ switch config.Logging.Output {
+ case "stderr":
+ output = os.Stderr
+ case "stdout":
+ output = os.Stdout
+ default:
+ output = os.Stdout
+ }
+
+ handler := slog.NewTextHandler(output, &slog.HandlerOptions{
+ Level: level,
+ })
+
+ logger := slog.New(handler)
+
+ return &Logger{logger, config}
+}
diff --git a/internal/screencapturer/interface.go b/internal/screencapturer/interface.go
new file mode 100644
index 0000000..6045dcd
--- /dev/null
+++ b/internal/screencapturer/interface.go
@@ -0,0 +1,27 @@
+// 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
+
+type ScreenCapturer interface {
+ Init() error
+ Start() error
+ Stop() error
+ Get() (filepath string, err error)
+}
diff --git a/internal/screencapturer/wholescreencapturer.go b/internal/screencapturer/wholescreencapturer.go
new file mode 100644
index 0000000..8b00a09
--- /dev/null
+++ b/internal/screencapturer/wholescreencapturer.go
@@ -0,0 +1,241 @@
+// 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
+ interval int
+ bufferCount int
+ tempDirectory string
+ initialized bool
+ running bool
+
+ files []string
+ latest string
+ done chan struct{}
+ sync.WaitGroup
+ mu sync.RWMutex
+}
+
+// Get implements ScreenCapturer.
+func (w *wholeScreenCapturer) Get() (filepath string, err error) {
+ w.mu.RLock()
+ defer w.mu.Unlock()
+ if !w.initialized {
+ return "", ErrNotInitialized
+ }
+ if w.latest == "" {
+ return "", errors.New("no screenshot captured yet")
+ }
+ return w.latest, nil
+}
+
+// Init implements ScreenCapturer.
+func (w *wholeScreenCapturer) Init() (err error) {
+
+ 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.interval = w.config.Screenshot.Interval
+ w.log.Debug("screenshot interval set", "interval", w.interval)
+
+ 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.latest = ""
+ w.initialized = true
+ return nil
+}
+
+// Start implements ScreenCapturer.
+func (w *wholeScreenCapturer) Start() error {
+ if !w.initialized {
+ return ErrNotInitialized
+ }
+ if w.running {
+ w.log.Debug("wholescreencapturer is already running, ignoring start")
+ return nil
+ }
+ w.running = true
+ w.Go(func() {
+ w.captureLoop()
+ })
+ w.log.Debug("wholescreencapturer started")
+ return nil
+}
+
+// Stop implements ScreenCapturer.
+func (w *wholeScreenCapturer) Stop() error {
+ if !w.initialized {
+ return ErrNotInitialized
+ }
+ if !w.running {
+ w.log.Debug("wholescreencapturer is not running, ignoring stop")
+ return nil
+ }
+ w.log.Debug("stopping wholescreencapturer")
+ close(w.done)
+ w.Wait()
+ w.running = false
+ w.log.Debug("wholescreencapturer stopped")
+ return nil
+}
+
+func (w *wholeScreenCapturer) captureLoop() {
+ ticker := time.NewTicker(time.Duration(w.interval) * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-w.done:
+ w.log.Debug("capture loop stopped")
+ return
+ case <-ticker.C:
+ fp, err := w.captureAndSave()
+ if err != nil {
+ w.log.Error("screenshot capture failed", "error", err)
+ continue
+ }
+ w.addToBuffer(fp)
+ }
+ }
+}
+
+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)
+ w.latest = 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,
+ done: make(chan struct{}),
+ }
+ lc.Append(fx.StopHook(func(ctx context.Context) error {
+ if !capturer.initialized {
+ log.Debug("wholescreencapturer not initialized, nothing to do")
+ return nil
+ }
+
+ log.Debug("stopping wholescreencapturer")
+
+ err := capturer.Stop()
+ if err != nil {
+ log.Error("failed to stop wholescreencapturer gracefully")
+ return 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..45881eb
--- /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, "error", err)
+ 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