refactor: project structure, makefile
This commit is contained in:
75
internal/browserlauncher/browserlauncher.go
Normal file
75
internal/browserlauncher/browserlauncher.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
224
internal/config/config.go
Normal file
224
internal/config/config.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
24
internal/constants/app.go
Normal file
24
internal/constants/app.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package constants
|
||||
|
||||
const (
|
||||
AppName = "auto-attendance"
|
||||
)
|
||||
88
internal/linkvalidator/linkvalidator.go
Normal file
88
internal/linkvalidator/linkvalidator.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
66
internal/logger/logger.go
Normal file
66
internal/logger/logger.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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}
|
||||
}
|
||||
27
internal/screencapturer/interface.go
Normal file
27
internal/screencapturer/interface.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package screencapturer
|
||||
|
||||
type ScreenCapturer interface {
|
||||
Init() error
|
||||
Start() error
|
||||
Stop() error
|
||||
Get() (filepath string, err error)
|
||||
}
|
||||
241
internal/screencapturer/wholescreencapturer.go
Normal file
241
internal/screencapturer/wholescreencapturer.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
89
internal/vision/vision.go
Normal file
89
internal/vision/vision.go
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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}
|
||||
}
|
||||
3
internal/vision/visiondata.go
Normal file
3
internal/vision/visiondata.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package vision
|
||||
|
||||
type VisionData []string
|
||||
Reference in New Issue
Block a user