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