mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 12:43:12 +02:00
chore: optimize pre-commit, remove tracked binaries, fix lint issues
- Move slow hooks (mypy, pylint, bandit, pytest, prettier) to pre-push stage - Remove redundant autoflake (ruff covers F401/F841) - Fix shellcheck OOM by batching files with xargs -n 40 - Remove tracked .o, .wav, .pyc binaries from git - Move pomodoro wav files to ../testsAndMisc_binaries/ with symlinks - Add *.o, *.so, *.a to .gitignore - Refactor hltb._pick_best_hltb_entry to fix C901/PLR0911/SIM102 - Fix SC2034 warnings in gif_to_square.sh and upgrade.sh - Add disk_cleanup_check.sh script - Various test and code improvements across screen_locker, steam_backlog_enforcer, word_frequency, moviepy_showcase
This commit is contained in:
parent
7f2d2c4c39
commit
3ebb97b283
@ -117,6 +117,11 @@ C/misc/randomJPG/
|
||||
# Files with many embedded image references
|
||||
python_pkg/cinema_planner/pasted_content.txt
|
||||
|
||||
# Large data files (exceed indexing limits)
|
||||
python_pkg/keyboard_coop/words_dictionary.json
|
||||
linux_configuration/scripts/digital_wellbeing/pacman/words.txt
|
||||
python_pkg/word_frequency/test_texts/
|
||||
|
||||
# C build artifacts
|
||||
**/*.out
|
||||
**/random_engine
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -102,6 +102,9 @@ Thumbs.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
# Compiled
|
||||
*.o
|
||||
*.so
|
||||
*.a
|
||||
*.exe
|
||||
*.dll
|
||||
*.dylib
|
||||
@ -110,7 +113,16 @@ Thumbs.db
|
||||
!pomodoro_app/android/app/src/main/res/mipmap-*/ic_launcher.png
|
||||
!pomodoro_app/packaging/arch/pomodoro-app.svg
|
||||
!TS/battery-status/public/favicon.svg
|
||||
!horatio/horatio_app/assets/demo_recordings/*.wav
|
||||
|
||||
# Binary asset symlinks (point to ../testsAndMisc_binaries/)
|
||||
pomodoro_app/assets/sounds/long_break_done.wav
|
||||
pomodoro_app/assets/sounds/long_break_start.wav
|
||||
pomodoro_app/assets/sounds/short_break_done.wav
|
||||
pomodoro_app/assets/sounds/work_done.wav
|
||||
horatio/horatio_app/assets/demo_recordings/hamlet_line0_take1.wav
|
||||
horatio/horatio_app/assets/demo_recordings/hamlet_line0_take2.wav
|
||||
horatio/horatio_app/assets/demo_recordings/hamlet_line0_take3.wav
|
||||
horatio/horatio_app/assets/demo_recordings/hamlet_line1_take1.wav
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
# ==============================================================================
|
||||
# Pre-commit Configuration - Multi-language Linting & Formatting
|
||||
# ==============================================================================
|
||||
# Install: pre-commit install
|
||||
# Run all: pre-commit run --all-files
|
||||
# Update hooks: pre-commit autoupdate
|
||||
# Install: pre-commit install && pre-commit install --hook-type pre-push
|
||||
# Fast lint: pre-commit run --all-files (linters only, ~10 s)
|
||||
# Full suite: pre-commit run --all-files --hook-stage pre-push (+ tests)
|
||||
# Update hooks: pre-commit autoupdate
|
||||
# ==============================================================================
|
||||
|
||||
# Global settings
|
||||
@ -101,12 +102,13 @@ repos:
|
||||
types_or: [python, pyi]
|
||||
|
||||
# ===========================================================================
|
||||
# MYPY - Static type checking (minimal for scripts repository)
|
||||
# MYPY - Static type checking (runs on push only for speed)
|
||||
# ===========================================================================
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.13.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
stages: [pre-push]
|
||||
args:
|
||||
- --ignore-missing-imports
|
||||
- --no-error-summary
|
||||
@ -144,12 +146,13 @@ repos:
|
||||
- types-python-dateutil
|
||||
|
||||
# ===========================================================================
|
||||
# PYLINT - Comprehensive Python linter
|
||||
# PYLINT - Comprehensive Python linter (runs on push only for speed)
|
||||
# ===========================================================================
|
||||
- repo: https://github.com/pylint-dev/pylint
|
||||
rev: v3.3.2
|
||||
hooks:
|
||||
- id: pylint
|
||||
stages: [pre-push]
|
||||
args:
|
||||
- --rcfile=pyproject.toml
|
||||
- --fail-under=8.0
|
||||
@ -162,12 +165,13 @@ repos:
|
||||
exclude: ^(Bash/|\.venv/)
|
||||
|
||||
# ===========================================================================
|
||||
# BANDIT - Security linter (relaxed for scripts)
|
||||
# BANDIT - Security linter (runs on push only for speed)
|
||||
# ===========================================================================
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.7.10
|
||||
hooks:
|
||||
- id: bandit
|
||||
stages: [pre-push]
|
||||
args:
|
||||
- -c
|
||||
- pyproject.toml
|
||||
@ -180,6 +184,7 @@ repos:
|
||||
# ===========================================================================
|
||||
# PYTEST + COVERAGE - Run tests and enforce 100% code coverage
|
||||
# Only tests for subpackages with changed files are run (see script).
|
||||
# Runs on push only (slow); use --hook-stage pre-push to run manually.
|
||||
# ===========================================================================
|
||||
- repo: local
|
||||
hooks:
|
||||
@ -189,6 +194,7 @@ repos:
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: true
|
||||
stages: [pre-push]
|
||||
|
||||
# ===========================================================================
|
||||
# VULTURE - Dead code detection (disabled - doesn't work well with pre-commit)
|
||||
@ -259,17 +265,18 @@ repos:
|
||||
|
||||
# ===========================================================================
|
||||
# AUTOFLAKE - Remove unused imports/variables
|
||||
# Disabled: fully redundant with ruff (F401, F841, F811) + --fix
|
||||
# ===========================================================================
|
||||
- repo: https://github.com/PyCQA/autoflake
|
||||
rev: v2.3.1
|
||||
hooks:
|
||||
- id: autoflake
|
||||
args:
|
||||
- --in-place
|
||||
- --remove-all-unused-imports
|
||||
- --remove-unused-variables
|
||||
- --remove-duplicate-keys
|
||||
- --expand-star-imports
|
||||
# - repo: https://github.com/PyCQA/autoflake
|
||||
# rev: v2.3.1
|
||||
# hooks:
|
||||
# - id: autoflake
|
||||
# args:
|
||||
# - --in-place
|
||||
# - --remove-all-unused-imports
|
||||
# - --remove-unused-variables
|
||||
# - --remove-duplicate-keys
|
||||
# - --expand-star-imports
|
||||
|
||||
# ===========================================================================
|
||||
# SAFETY - Check for security vulnerabilities in dependencies
|
||||
@ -291,7 +298,7 @@ repos:
|
||||
# - id: pyright
|
||||
|
||||
# ===========================================================================
|
||||
# CHECK JSON/YAML/TOML formatting
|
||||
# CHECK JSON/YAML/TOML formatting (runs on push only — slow Node.js startup)
|
||||
# ===========================================================================
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v4.0.0-alpha.8
|
||||
@ -299,15 +306,19 @@ repos:
|
||||
- id: prettier
|
||||
types_or: [yaml, json, markdown]
|
||||
exclude: ^(Bash/|\.venv/|.*\.lock$|C/compile_commands\.json)
|
||||
stages: [pre-push]
|
||||
|
||||
# ===========================================================================
|
||||
# SHELLCHECK - Shell script linting
|
||||
# Wrapper batches files to avoid OOM on large repos.
|
||||
# ===========================================================================
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.10.0.1
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
args: [--severity=warning]
|
||||
name: shellcheck
|
||||
entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' --
|
||||
language: system
|
||||
types: [shell]
|
||||
exclude: ^pomodoro_app/
|
||||
|
||||
# ===========================================================================
|
||||
@ -427,7 +438,7 @@ repos:
|
||||
# stages: [push]
|
||||
|
||||
# ===========================================================================
|
||||
# POMODORO APP - Flutter analyze + test
|
||||
# POMODORO APP - Flutter analyze + test (push only)
|
||||
# ===========================================================================
|
||||
- repo: local
|
||||
hooks:
|
||||
@ -437,9 +448,10 @@ repos:
|
||||
language: system
|
||||
files: ^pomodoro_app/
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
|
||||
# ===========================================================================
|
||||
# HORATIO - Dart/Flutter tests with coverage enforcement
|
||||
# HORATIO - Dart/Flutter tests with coverage enforcement (push only)
|
||||
# ===========================================================================
|
||||
- repo: local
|
||||
hooks:
|
||||
@ -448,4 +460,5 @@ repos:
|
||||
entry: bash -c 'cd horatio && bash run.sh test'
|
||||
language: system
|
||||
files: ^horatio/
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
|
||||
@ -16,7 +16,7 @@ Checks: >
|
||||
-readability-isolate-declaration,
|
||||
-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling
|
||||
|
||||
WarningsAsErrors: ''
|
||||
WarningsAsErrors: ""
|
||||
HeaderFilterRegex: '.*\.h$'
|
||||
AnalyzeTemporaryDtors: false
|
||||
FormatStyle: file
|
||||
|
||||
66
C/imageViewer/.vscode/settings.json
vendored
66
C/imageViewer/.vscode/settings.json
vendored
@ -1,43 +1,35 @@
|
||||
{
|
||||
"C_Cpp.default.cStandard": "c99",
|
||||
"C_Cpp.default.intelliSenseMode": "linux-gcc-x64",
|
||||
"C_Cpp.default.includePath": [
|
||||
"${workspaceFolder}/**",
|
||||
"/usr/include/SDL2"
|
||||
],
|
||||
"C_Cpp.default.defines": [
|
||||
"_REENTRANT"
|
||||
],
|
||||
"C_Cpp.default.compilerPath": "/usr/bin/gcc",
|
||||
"C_Cpp.clang_format_style": "file",
|
||||
"C_Cpp.clang_format_fallbackStyle": "LLVM",
|
||||
"C_Cpp.default.cStandard": "c99",
|
||||
"C_Cpp.default.intelliSenseMode": "linux-gcc-x64",
|
||||
"C_Cpp.default.includePath": ["${workspaceFolder}/**", "/usr/include/SDL2"],
|
||||
"C_Cpp.default.defines": ["_REENTRANT"],
|
||||
"C_Cpp.default.compilerPath": "/usr/bin/gcc",
|
||||
"C_Cpp.clang_format_style": "file",
|
||||
"C_Cpp.clang_format_fallbackStyle": "LLVM",
|
||||
|
||||
"files.associations": {
|
||||
"*.h": "c",
|
||||
"*.c": "c"
|
||||
},
|
||||
"files.associations": {
|
||||
"*.h": "c",
|
||||
"*.c": "c"
|
||||
},
|
||||
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.rulers": [100],
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.rulers": [100],
|
||||
|
||||
"clang-tidy.executable": "clang-tidy",
|
||||
"clang-tidy.checks": [
|
||||
"clang-diagnostic-*",
|
||||
"clang-analyzer-*",
|
||||
"bugprone-*",
|
||||
"cert-*",
|
||||
"misc-*",
|
||||
"performance-*",
|
||||
"portability-*",
|
||||
"readability-*"
|
||||
],
|
||||
"clang-tidy.executable": "clang-tidy",
|
||||
"clang-tidy.checks": [
|
||||
"clang-diagnostic-*",
|
||||
"clang-analyzer-*",
|
||||
"bugprone-*",
|
||||
"cert-*",
|
||||
"misc-*",
|
||||
"performance-*",
|
||||
"portability-*",
|
||||
"readability-*"
|
||||
],
|
||||
|
||||
"cppcheck.enable": true,
|
||||
"cppcheck.standard": ["c99"],
|
||||
"cppcheck.suppress": [
|
||||
"missingIncludeSystem",
|
||||
"unusedFunction"
|
||||
]
|
||||
"cppcheck.enable": true,
|
||||
"cppcheck.standard": ["c99"],
|
||||
"cppcheck.suppress": ["missingIncludeSystem", "unusedFunction"]
|
||||
}
|
||||
|
||||
@ -5,26 +5,31 @@ This directory contains a comprehensive linting setup for the imageViewer C proj
|
||||
## Tools Included
|
||||
|
||||
### 1. **clang-tidy** - Static Analysis
|
||||
|
||||
- Configuration: `.clang-tidy`
|
||||
- Checks for bugs, performance issues, and style violations
|
||||
- Enforces modern C coding standards
|
||||
|
||||
### 2. **clang-format** - Code Formatting
|
||||
### 2. **clang-format** - Code Formatting
|
||||
|
||||
- Configuration: `.clang-format`
|
||||
- Automatically formats code to consistent style
|
||||
- 100-character line limit, 4-space indentation
|
||||
|
||||
### 3. **cppcheck** - Additional Static Analysis
|
||||
|
||||
- Detects memory leaks, null pointer dereferences
|
||||
- Checks for undefined behavior
|
||||
|
||||
### 4. **gcc with warnings** - Compiler Analysis
|
||||
|
||||
- Comprehensive warning flags
|
||||
- Standards compliance checking
|
||||
|
||||
## Usage
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies (Arch Linux)
|
||||
make deps-arch
|
||||
@ -43,6 +48,7 @@ make memcheck
|
||||
```
|
||||
|
||||
### Individual Commands
|
||||
|
||||
```bash
|
||||
# Manual linting
|
||||
./lint.sh
|
||||
@ -60,12 +66,14 @@ cppcheck --enable=all main.c
|
||||
## VS Code Integration
|
||||
|
||||
The `.vscode/settings.json` file provides:
|
||||
|
||||
- Automatic formatting on save
|
||||
- C99 standard compliance
|
||||
- IntelliSense configuration for SDL2
|
||||
- Integrated linting with clang-tidy and cppcheck
|
||||
|
||||
## Recommended Extensions for VS Code
|
||||
|
||||
- C/C++ (Microsoft)
|
||||
- clang-tidy (mine-cetinkaya-fianso)
|
||||
- cppcheck (unixwrapped)
|
||||
@ -73,16 +81,18 @@ The `.vscode/settings.json` file provides:
|
||||
## Linting Rules
|
||||
|
||||
### Enabled Checks
|
||||
- **clang-diagnostic-***: Compiler diagnostics
|
||||
- **clang-analyzer-***: Static analysis
|
||||
- **bugprone-***: Bug-prone patterns
|
||||
- **cert-***: CERT secure coding standards
|
||||
- **misc-***: Miscellaneous checks
|
||||
- **performance-***: Performance improvements
|
||||
- **portability-***: Cross-platform issues
|
||||
- **readability-***: Code readability
|
||||
|
||||
- **clang-diagnostic-\***: Compiler diagnostics
|
||||
- **clang-analyzer-\***: Static analysis
|
||||
- **bugprone-\***: Bug-prone patterns
|
||||
- **cert-\***: CERT secure coding standards
|
||||
- **misc-\***: Miscellaneous checks
|
||||
- **performance-\***: Performance improvements
|
||||
- **portability-\***: Cross-platform issues
|
||||
- **readability-\***: Code readability
|
||||
|
||||
### Disabled Checks
|
||||
|
||||
- `readability-magic-numbers`: Allows constants like window dimensions
|
||||
- `cert-err33-c`: Allows ignoring some function return values
|
||||
- `misc-unused-parameters`: Common in callback functions
|
||||
@ -107,16 +117,19 @@ The `.vscode/settings.json` file provides:
|
||||
## Installation on Different Systems
|
||||
|
||||
### Arch Linux
|
||||
|
||||
```bash
|
||||
sudo pacman -S clang cppcheck valgrind
|
||||
```
|
||||
|
||||
### Ubuntu/Debian
|
||||
|
||||
```bash
|
||||
sudo apt install clang-tidy cppcheck clang-format valgrind
|
||||
```
|
||||
|
||||
### Fedora/RHEL
|
||||
|
||||
```bash
|
||||
sudo dnf install clang-tools-extra cppcheck clang valgrind
|
||||
```
|
||||
|
||||
@ -16,22 +16,26 @@ A simple, lightweight image viewer written in C using SDL2. Supports JPG, PNG, B
|
||||
The image viewer requires SDL2 and SDL2_image libraries.
|
||||
|
||||
### Ubuntu/Debian
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install libsdl2-dev libsdl2-image-dev
|
||||
```
|
||||
|
||||
### Fedora/RHEL/CentOS
|
||||
|
||||
```bash
|
||||
sudo dnf install SDL2-devel SDL2_image-devel
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
|
||||
```bash
|
||||
sudo pacman -S sdl2 sdl2_image
|
||||
```
|
||||
|
||||
### macOS (with Homebrew)
|
||||
|
||||
```bash
|
||||
brew install sdl2 sdl2_image
|
||||
```
|
||||
@ -39,6 +43,7 @@ brew install sdl2 sdl2_image
|
||||
## Installation
|
||||
|
||||
### Arch Linux (Automated)
|
||||
|
||||
For Arch Linux users, there's an automated installation script that handles everything:
|
||||
|
||||
```bash
|
||||
@ -46,6 +51,7 @@ For Arch Linux users, there's an automated installation script that handles ever
|
||||
```
|
||||
|
||||
This script will:
|
||||
|
||||
- Install SDL2 dependencies via pacman
|
||||
- Build the imageviewer from source
|
||||
- Install the binary to `/usr/local/bin`
|
||||
@ -53,6 +59,7 @@ This script will:
|
||||
- Set up file associations
|
||||
|
||||
To uninstall:
|
||||
|
||||
```bash
|
||||
./uninstall_arch.sh
|
||||
```
|
||||
@ -61,14 +68,16 @@ To uninstall:
|
||||
|
||||
1. Install dependencies (see above)
|
||||
2. Build the project:
|
||||
|
||||
```bash
|
||||
make
|
||||
```
|
||||
|
||||
Or use the dependency helper:
|
||||
|
||||
```bash
|
||||
make deps-debian # For Ubuntu/Debian
|
||||
make deps-fedora # For Fedora/RHEL/CentOS
|
||||
make deps-fedora # For Fedora/RHEL/CentOS
|
||||
make deps-arch # For Arch Linux
|
||||
make
|
||||
```
|
||||
@ -80,6 +89,7 @@ make
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
./imageviewer photo.jpg
|
||||
./imageviewer ../misc/randomJPG/14k/bloated_image_1.jpg
|
||||
@ -87,35 +97,39 @@ Example:
|
||||
|
||||
## Controls
|
||||
|
||||
| Control | Action |
|
||||
|---------|--------|
|
||||
| **Mouse wheel** | Zoom in/out |
|
||||
| **+ / -** | Zoom in/out (keyboard) |
|
||||
| **Mouse drag** | Pan around the image |
|
||||
| **R** | Reset zoom and position to default |
|
||||
| **F** | Fit image to window |
|
||||
| **H** | Show help in console |
|
||||
| **ESC / Q** | Quit the application |
|
||||
| Control | Action |
|
||||
| --------------- | ---------------------------------- |
|
||||
| **Mouse wheel** | Zoom in/out |
|
||||
| **+ / -** | Zoom in/out (keyboard) |
|
||||
| **Mouse drag** | Pan around the image |
|
||||
| **R** | Reset zoom and position to default |
|
||||
| **F** | Fit image to window |
|
||||
| **H** | Show help in console |
|
||||
| **ESC / Q** | Quit the application |
|
||||
|
||||
## Features in Detail
|
||||
|
||||
### Zooming
|
||||
|
||||
- Use mouse wheel to zoom in/out at the mouse cursor position
|
||||
- Keyboard shortcuts: `+` to zoom in, `-` to zoom out
|
||||
- Zoom range: 0.1x to 10x
|
||||
- Smart zoom behavior focuses on mouse position
|
||||
|
||||
### Panning
|
||||
|
||||
- Click and drag with left mouse button to move around zoomed images
|
||||
- Smooth panning for precise positioning
|
||||
- Works at any zoom level
|
||||
|
||||
### Auto-fit
|
||||
|
||||
- Large images are automatically scaled to fit the window when first loaded
|
||||
- Press `F` to manually fit the current image to window
|
||||
- Maintains aspect ratio
|
||||
|
||||
### Window Management
|
||||
|
||||
- Resizable window that adapts to content
|
||||
- Real-time rendering updates
|
||||
- Dark background for better image contrast
|
||||
@ -141,7 +155,9 @@ Example:
|
||||
## Troubleshooting
|
||||
|
||||
### "SDL could not initialize" Error
|
||||
|
||||
Make sure SDL2 development libraries are installed:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install libsdl2-dev libsdl2-image-dev
|
||||
@ -151,11 +167,13 @@ pkg-config --libs sdl2
|
||||
```
|
||||
|
||||
### "Unable to load image" Error
|
||||
|
||||
- Check that the image file exists and is readable
|
||||
- Verify the image format is supported (JPG, PNG, BMP, GIF, TIF)
|
||||
- Try with a different image file to isolate the issue
|
||||
|
||||
### Compilation Errors
|
||||
|
||||
- Ensure you have a C compiler installed (gcc or clang)
|
||||
- Check that SDL2 headers are available
|
||||
- Try rebuilding with `make clean && make`
|
||||
|
||||
@ -7,6 +7,7 @@ The imageviewer project uses secure coding practices with proper bounds checking
|
||||
### Why These Warnings Appear
|
||||
|
||||
The static analyzer flags standard C library functions like:
|
||||
|
||||
- `memcpy()` - suggests using `memcpy_s()`
|
||||
- `snprintf()` - suggests using `snprintf_s()`
|
||||
- `strncpy()` - suggests using `strncpy_s()`
|
||||
@ -34,9 +35,10 @@ if (ret < 0 || ret >= sizeof(full_path)) {
|
||||
}
|
||||
```
|
||||
|
||||
### Microsoft-Specific _s Functions
|
||||
### Microsoft-Specific \_s Functions
|
||||
|
||||
The suggested `_s` functions (like `memcpy_s`, `snprintf_s`) are:
|
||||
|
||||
- Microsoft-specific extensions
|
||||
- Not part of standard C
|
||||
- Not portable to Linux/Unix systems
|
||||
@ -47,6 +49,7 @@ The suggested `_s` functions (like `memcpy_s`, `snprintf_s`) are:
|
||||
**Status**: ✅ **SECURE**
|
||||
|
||||
The current implementation is secure because:
|
||||
|
||||
- All buffer operations are bounds-checked
|
||||
- No user input is directly copied without validation
|
||||
- File paths are validated for maximum length
|
||||
@ -63,6 +66,7 @@ For development, these specific warnings can be suppressed since the code has be
|
||||
```
|
||||
|
||||
Or use NOLINT comments for specific lines:
|
||||
|
||||
```c
|
||||
memcpy(dest, src, len); // NOLINT(clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling)
|
||||
```
|
||||
@ -70,6 +74,7 @@ memcpy(dest, src, len); // NOLINT(clang-analyzer-security.insecureAPI.Deprecated
|
||||
### Verification
|
||||
|
||||
To verify security:
|
||||
|
||||
1. ✅ All string operations use explicit length checking
|
||||
2. ✅ Buffer overflow conditions are detected and handled
|
||||
3. ✅ No direct user input to buffer operations
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1 +0,0 @@
|
||||
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take1.wav
|
||||
@ -1 +0,0 @@
|
||||
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take2.wav
|
||||
@ -1 +0,0 @@
|
||||
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take3.wav
|
||||
@ -1 +0,0 @@
|
||||
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line1_take1.wav
|
||||
Binary file not shown.
Binary file not shown.
@ -66,7 +66,8 @@ fi
|
||||
|
||||
INPUT_GIF="$1"
|
||||
OUTPUT_GIF="${2:-}"
|
||||
BACKGROUND="${3:-${DEFAULT_BG}}"
|
||||
BACKGROUND="${3:-${DEFAULT_BG}}" # Reserved for future padding mode
|
||||
export BACKGROUND
|
||||
|
||||
# Validate input file exists
|
||||
if [[ ! -f ${INPUT_GIF} ]]; then
|
||||
|
||||
@ -13,7 +13,6 @@ log() { printf '[upgrade] %s\n' "$*"; }
|
||||
# =====================================================================
|
||||
fix_duplicate_chrome_edge_repo() {
|
||||
local edge="/etc/apt/sources.list.d/microsoft-edge.list"
|
||||
local chrome="/etc/apt/sources.list.d/google-chrome.list"
|
||||
|
||||
if [[ ! -f $edge ]]; then
|
||||
return
|
||||
@ -57,7 +56,6 @@ fix_wine_legacy_key() {
|
||||
local legacy_keyring="/etc/apt/trusted.gpg"
|
||||
local wine_key_id="D43F640145369C51D786DDEA76F1A20FF987672F"
|
||||
local modern_keyring="/usr/share/keyrings/winehq-archive.gpg"
|
||||
local wine_source="/etc/apt/sources.list.d/winehq-focal.list"
|
||||
|
||||
# Check if wine key is in the legacy keyring
|
||||
if ! gpg --no-default-keyring --keyring "$legacy_keyring" --list-keys "$wine_key_id" >/dev/null 2>&1; then
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -6,6 +6,7 @@ so source modules can be imported without moviepy installed.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@ -70,25 +71,38 @@ _clip_classes = [
|
||||
"AudioArrayClip",
|
||||
"CompositeAudioClip",
|
||||
]
|
||||
for _cls in _clip_classes:
|
||||
getattr(mock_moviepy, _cls).side_effect = lambda *_a, **_kw: create_mock_clip()
|
||||
|
||||
mock_moviepy.concatenate_videoclips.side_effect = lambda *_a, **_kw: create_mock_clip()
|
||||
mock_moviepy.concatenate_audioclips.side_effect = lambda *_a, **_kw: create_mock_clip()
|
||||
mock_moviepy.video.compositing.CompositeVideoClip.clips_array.side_effect = (
|
||||
lambda *_a, **_kw: create_mock_clip()
|
||||
)
|
||||
_drawing_shape = (_H, _W)
|
||||
|
||||
# Drawing tools must return real numpy arrays (used in numpy ops)
|
||||
mock_moviepy.video.tools.drawing.circle.return_value = np.zeros(
|
||||
(_H, _W), dtype=np.float64
|
||||
)
|
||||
mock_moviepy.video.tools.drawing.color_gradient.return_value = np.zeros(
|
||||
(_H, _W), dtype=np.float64
|
||||
)
|
||||
mock_moviepy.video.tools.drawing.color_split.return_value = np.zeros(
|
||||
(_H, _W), dtype=np.float64
|
||||
)
|
||||
|
||||
def _reset_side_effects() -> None:
|
||||
"""(Re)apply side_effect / return_value on the shared mock_moviepy."""
|
||||
for cls_name in _clip_classes:
|
||||
getattr(mock_moviepy, cls_name).side_effect = lambda *_a, **_kw: (
|
||||
create_mock_clip()
|
||||
)
|
||||
mock_moviepy.concatenate_videoclips.side_effect = lambda *_a, **_kw: (
|
||||
create_mock_clip()
|
||||
)
|
||||
mock_moviepy.concatenate_audioclips.side_effect = lambda *_a, **_kw: (
|
||||
create_mock_clip()
|
||||
)
|
||||
mock_moviepy.video.compositing.CompositeVideoClip.clips_array.side_effect = (
|
||||
lambda *_a, **_kw: create_mock_clip()
|
||||
)
|
||||
# Drawing tools must return real numpy arrays (used in numpy ops)
|
||||
mock_moviepy.video.tools.drawing.circle.return_value = np.zeros(
|
||||
_drawing_shape, dtype=np.float64
|
||||
)
|
||||
mock_moviepy.video.tools.drawing.color_gradient.return_value = np.zeros(
|
||||
_drawing_shape, dtype=np.float64
|
||||
)
|
||||
mock_moviepy.video.tools.drawing.color_split.return_value = np.zeros(
|
||||
_drawing_shape, dtype=np.float64
|
||||
)
|
||||
|
||||
|
||||
_reset_side_effects()
|
||||
|
||||
# ── Install into sys.modules ─────────────────────────────────────
|
||||
_module_paths = [
|
||||
@ -117,7 +131,21 @@ def _install_moviepy_mocks() -> None:
|
||||
_install_moviepy_mocks()
|
||||
|
||||
|
||||
_source_modules = [
|
||||
"python_pkg.moviepy_showcase.moviepy_showcase",
|
||||
"python_pkg.moviepy_showcase._moviepy_clip_types",
|
||||
"python_pkg.moviepy_showcase._moviepy_video_effects",
|
||||
"python_pkg.moviepy_showcase._moviepy_audio_output",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reinstall_moviepy_mocks() -> None:
|
||||
"""Ensure our moviepy mocks are active even if another conftest overwrote."""
|
||||
_install_moviepy_mocks()
|
||||
_reset_side_effects()
|
||||
# Re-bind cached ``from moviepy import X`` references in source modules
|
||||
# that may point to stale MagicMock children from a prior sys.modules entry.
|
||||
for mod_name in _source_modules:
|
||||
if mod_name in sys.modules:
|
||||
importlib.reload(sys.modules[mod_name])
|
||||
|
||||
@ -4,10 +4,8 @@
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
|
||||
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
|
||||
TIMER_FILE="$SCRIPT_DIR/workout-locker.timer"
|
||||
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
||||
SERVICE_NAME="workout-locker.service"
|
||||
TIMER_NAME="workout-locker.timer"
|
||||
|
||||
# Check if service is already installed
|
||||
if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then
|
||||
@ -26,9 +24,14 @@ fi
|
||||
# Create user systemd directory if it doesn't exist
|
||||
mkdir -p "$USER_SERVICE_DIR"
|
||||
|
||||
# Copy service and timer files to user systemd directory
|
||||
# Remove old timer if it was previously installed
|
||||
if systemctl --user is-active "workout-locker.timer" &>/dev/null; then
|
||||
systemctl --user disable --now "workout-locker.timer" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$USER_SERVICE_DIR/workout-locker.timer"
|
||||
|
||||
# Copy service file to user systemd directory
|
||||
cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
||||
cp "$TIMER_FILE" "$USER_SERVICE_DIR/$TIMER_NAME"
|
||||
|
||||
# Update paths in the service file to use absolute paths
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
@ -39,18 +42,16 @@ sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 -m python_pkg.
|
||||
# Reload systemd daemon
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# Enable the service to start on login and the timer for periodic checks
|
||||
# Enable the service to start on login (one-shot, no periodic timer)
|
||||
systemctl --user enable "$SERVICE_NAME"
|
||||
systemctl --user enable --now "$TIMER_NAME"
|
||||
|
||||
echo "✓ Workout locker service installed"
|
||||
echo "✓ Service will start automatically on next login"
|
||||
echo ""
|
||||
echo "To start now: systemctl --user start workout-locker"
|
||||
echo "To check status: systemctl --user status workout-locker"
|
||||
echo "To check timer: systemctl --user list-timers workout-locker.timer"
|
||||
echo "To stop: systemctl --user stop workout-locker"
|
||||
echo "To disable autostart: systemctl --user disable workout-locker workout-locker.timer"
|
||||
echo "To disable autostart: systemctl --user disable workout-locker"
|
||||
|
||||
# Check autostart installation status
|
||||
echo ""
|
||||
|
||||
@ -50,6 +50,21 @@ __all__ = [
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _assert_not_under_pytest() -> None:
|
||||
"""Raise if the screen locker is being created inside a pytest run.
|
||||
|
||||
Defence-in-depth: prevents a real fullscreen Tk window from locking
|
||||
the user's screen when tests forget to mock ``tk.Tk``.
|
||||
The check is cheap (one dict lookup) and only fires during testing.
|
||||
"""
|
||||
if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter":
|
||||
msg = (
|
||||
"SAFETY: ScreenLocker.__init__ called under pytest with "
|
||||
"real tkinter — tk.Tk is not mocked"
|
||||
)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
class ScreenLocker(
|
||||
ShutdownMixin,
|
||||
PhoneVerificationMixin,
|
||||
@ -64,6 +79,7 @@ class ScreenLocker(
|
||||
verify_only: bool = False,
|
||||
) -> None:
|
||||
"""Initialize screen locker with optional demo mode."""
|
||||
_assert_not_under_pytest()
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
self.log_file = script_dir / "workout_log.json"
|
||||
self.verify_only = verify_only
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
"""Shared fixtures and helpers for screen_locker tests."""
|
||||
"""Shared fixtures and helpers for screen_locker tests.
|
||||
|
||||
Safety:
|
||||
``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk``
|
||||
module reference inside ``screen_lock`` with a MagicMock and stubs
|
||||
``sys.exit``. This makes it physically impossible for any test to
|
||||
create a real Tk root window, go fullscreen, or grab input — even if
|
||||
the test forgets to request the explicit ``mock_tk`` fixture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -12,7 +20,40 @@ import pytest
|
||||
from python_pkg.screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
|
||||
def _make_mock_tk() -> MagicMock:
|
||||
"""Build a MagicMock that stands in for the ``tkinter`` module."""
|
||||
mock = MagicMock()
|
||||
mock_root = MagicMock()
|
||||
mock_root.winfo_screenwidth.return_value = 1920
|
||||
mock_root.winfo_screenheight.return_value = 1080
|
||||
mock.Tk.return_value = mock_root
|
||||
|
||||
mock_frame = MagicMock()
|
||||
mock_frame.winfo_children.return_value = []
|
||||
mock.Frame.return_value = mock_frame
|
||||
|
||||
# Keep real TclError so ``except tk.TclError`` still works.
|
||||
mock.TclError = tk.TclError
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _block_real_tk_and_exit() -> Iterator[None]:
|
||||
"""Replace the whole ``tk`` module and ``sys.exit`` for every test.
|
||||
|
||||
Patching the entire module (not just ``tk.Tk``) ensures that
|
||||
**nothing** in tkinter can touch the real display server.
|
||||
"""
|
||||
mock = _make_mock_tk()
|
||||
|
||||
with (
|
||||
patch("python_pkg.screen_locker.screen_lock.tk", mock),
|
||||
patch("python_pkg.screen_locker.screen_lock.sys.exit"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -10,12 +10,29 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.screen_locker.screen_lock import _assert_not_under_pytest
|
||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestAssertNotUnderPytest:
|
||||
"""Tests for the _assert_not_under_pytest runtime guard."""
|
||||
|
||||
def test_raises_when_tk_is_real(self) -> None:
|
||||
"""Guard fires if tk.Tk is the real tkinter class under pytest."""
|
||||
with (
|
||||
patch("python_pkg.screen_locker.screen_lock.tk", tk),
|
||||
pytest.raises(RuntimeError, match="SAFETY"),
|
||||
):
|
||||
_assert_not_under_pytest()
|
||||
|
||||
def test_silent_when_tk_is_mocked(self) -> None:
|
||||
"""Guard stays silent when tk is already mocked (normal test run)."""
|
||||
_assert_not_under_pytest()
|
||||
|
||||
|
||||
class TestScreenLockerInit:
|
||||
"""Tests for ScreenLocker initialization."""
|
||||
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Periodically check if workout was done today
|
||||
|
||||
[Timer]
|
||||
OnBootSec=5s
|
||||
OnUnitActiveSec=15min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@ -15,6 +15,29 @@ import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Real Steam directory — used as a safety check to block destructive
|
||||
# operations that leak through during testing.
|
||||
_REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser()
|
||||
|
||||
|
||||
def _assert_not_real_steam(path: Path) -> None:
|
||||
"""Raise if *path* is inside the real Steam directory.
|
||||
|
||||
Defence-in-depth guard: even if test fixtures fail to
|
||||
redirect ``STEAMAPPS_PATH``, destructive operations
|
||||
(uninstall, rmtree, unlink) will refuse to touch real files.
|
||||
"""
|
||||
try:
|
||||
path.resolve().relative_to(_REAL_STEAMAPPS.resolve())
|
||||
except ValueError:
|
||||
return # path is NOT under real Steam — safe to proceed
|
||||
if STEAMAPPS_PATH.resolve() == _REAL_STEAMAPPS.resolve():
|
||||
msg = (
|
||||
f"SAFETY: refusing destructive operation on real Steam path "
|
||||
f"{path!s} — STEAMAPPS_PATH was not redirected by test fixtures"
|
||||
)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None:
|
||||
"""Write user-facing CLI output to stdout.
|
||||
@ -56,6 +79,7 @@ PROTECTED_APP_IDS = {
|
||||
1007020, # Proton EasyAntiCheat Runtime
|
||||
# Games allowed to be installed anytime
|
||||
3949040, # RV There Yet?
|
||||
2252570,
|
||||
}
|
||||
|
||||
STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser()
|
||||
@ -312,6 +336,7 @@ def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool:
|
||||
game_name: Human-readable game name for logging.
|
||||
app_id: Steam application ID.
|
||||
"""
|
||||
_assert_not_real_steam(manifest)
|
||||
try:
|
||||
if manifest.exists():
|
||||
manifest.unlink()
|
||||
@ -333,6 +358,7 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool:
|
||||
"""
|
||||
success = True
|
||||
if install_dir and install_dir.is_dir():
|
||||
_assert_not_real_steam(install_dir)
|
||||
try:
|
||||
shutil.rmtree(install_dir)
|
||||
logger.info("Removed game files: %s", install_dir)
|
||||
@ -343,6 +369,7 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool:
|
||||
for subdir in ("shadercache", "compatdata"):
|
||||
cache_path = STEAMAPPS_PATH / subdir / str(app_id)
|
||||
if cache_path.is_dir():
|
||||
_assert_not_real_steam(cache_path)
|
||||
with contextlib.suppress(OSError):
|
||||
shutil.rmtree(cache_path)
|
||||
logger.debug("Removed %s/%d", subdir, app_id)
|
||||
|
||||
@ -154,6 +154,10 @@ def _pick_best_hltb_entry(
|
||||
When a short name like "FAITH" matches both "FAITH" (demo) and
|
||||
"FAITH: The Unholy Trinity" (full game), prefer the full game
|
||||
since Steam often lists the full game under the shorter name.
|
||||
|
||||
When an exact match like "Timberman" (26 h) competes against an
|
||||
unrelated subtitle entry like "Timberman: The Big Adventure" (2 h),
|
||||
the exact match wins because it has more hours.
|
||||
"""
|
||||
if not candidates:
|
||||
return None
|
||||
@ -165,36 +169,72 @@ def _pick_best_hltb_entry(
|
||||
return usable[0]
|
||||
|
||||
lower = search_name.lower()
|
||||
best_exact = _find_exact_match(usable, lower)
|
||||
best_extended = _find_best_extended(usable, lower)
|
||||
return _resolve_exact_vs_extended(best_exact, best_extended, usable)
|
||||
|
||||
|
||||
def _find_exact_match(
|
||||
usable: list[tuple[dict[str, Any], float]],
|
||||
lower: str,
|
||||
) -> tuple[dict[str, Any], float] | None:
|
||||
"""Find best exact name/alias match (highest comp_100)."""
|
||||
return next(
|
||||
(
|
||||
(e, s)
|
||||
for e, s in sorted(
|
||||
usable,
|
||||
key=lambda x: x[0].get("comp_100", 0),
|
||||
reverse=True,
|
||||
)
|
||||
if (e.get("game_name") or "").lower() == lower
|
||||
or (e.get("game_alias") or "").lower() == lower
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _find_best_extended(
|
||||
usable: list[tuple[dict[str, Any], float]],
|
||||
lower: str,
|
||||
) -> tuple[dict[str, Any], float] | None:
|
||||
"""Find best extended entry ("Name: Subtitle" / "Name - Subtitle").
|
||||
|
||||
Skips subset entries (prologue, demo, etc.).
|
||||
"""
|
||||
best: tuple[dict[str, Any], float] | None = None
|
||||
for entry, sim in usable:
|
||||
entry_name = (entry.get("game_name") or "").lower()
|
||||
if entry_name.startswith((lower + ":", lower + " -")):
|
||||
suffix = entry_name[len(lower) :].lstrip(" :-")
|
||||
if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES):
|
||||
# Only prefer this extended entry when it has strictly more
|
||||
# comp_100 than any exact-name match. This prevents
|
||||
# "Killing Floor: Toy Master" (1.2 h) from beating
|
||||
# "Killing Floor" (296 h) while still letting
|
||||
# "FAITH: The Unholy Trinity" (7 h) beat "FAITH" (0.5 h demo).
|
||||
extended_hours = entry.get("comp_100", 0)
|
||||
best_exact = next(
|
||||
(
|
||||
(e, s)
|
||||
for e, s in sorted(
|
||||
usable,
|
||||
key=lambda x: x[0].get("comp_100", 0),
|
||||
reverse=True,
|
||||
)
|
||||
if (e.get("game_name") or "").lower() == lower
|
||||
or (e.get("game_alias") or "").lower() == lower
|
||||
),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
best_exact is not None
|
||||
and best_exact[0].get("comp_100", 0) >= extended_hours
|
||||
):
|
||||
return best_exact
|
||||
return entry, sim
|
||||
if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES) and (
|
||||
best is None or entry.get("comp_100", 0) > best[0].get("comp_100", 0)
|
||||
):
|
||||
best = (entry, sim)
|
||||
return best
|
||||
|
||||
|
||||
def _resolve_exact_vs_extended(
|
||||
best_exact: tuple[dict[str, Any], float] | None,
|
||||
best_extended: tuple[dict[str, Any], float] | None,
|
||||
usable: list[tuple[dict[str, Any], float]],
|
||||
) -> tuple[dict[str, Any], float]:
|
||||
"""Decide between exact match, extended entry, or highest similarity."""
|
||||
if best_exact is not None and best_extended is not None:
|
||||
exact_hours = best_exact[0].get("comp_100", 0)
|
||||
extended_hours = best_extended[0].get("comp_100", 0)
|
||||
# Prefer the extended entry only when it has strictly more hours
|
||||
# than the exact match. This lets "FAITH: The Unholy Trinity"
|
||||
# (7 h) beat "FAITH" (0.5 h demo) while preventing
|
||||
# "Timberman: The Big Adventure" (2 h) from beating
|
||||
# "Timberman" (26 h).
|
||||
if extended_hours > exact_hours:
|
||||
return best_extended
|
||||
return best_exact
|
||||
if best_exact is not None:
|
||||
return best_exact
|
||||
if best_extended is not None:
|
||||
return best_extended
|
||||
|
||||
# Fall back to highest similarity.
|
||||
return max(usable, key=lambda x: x[1])
|
||||
|
||||
@ -7,12 +7,14 @@ to temporary directories. This stops tests from accidentally:
|
||||
user's current assignment)
|
||||
- Reading real appmanifest files from ~/.local/share/Steam/steamapps
|
||||
- Modifying /etc/hosts via the store blocker
|
||||
- Corrupting the HLTB cache on disk
|
||||
- Launching real Steam or calling real subprocess commands
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@ -57,6 +59,12 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
|
||||
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
|
||||
fake_steamapps,
|
||||
),
|
||||
# HLTB cache file (computed at import time from CONFIG_DIR, so
|
||||
# patching CONFIG_DIR alone does not redirect it)
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE",
|
||||
fake_config / "hltb_cache.json",
|
||||
),
|
||||
# /etc/hosts (store blocker)
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
|
||||
@ -68,3 +76,43 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _block_real_subprocesses() -> Iterator[None]:
|
||||
"""Block subprocess calls that could launch real Steam or modify system.
|
||||
|
||||
Individual tests that need to test subprocess behaviour should
|
||||
patch the specific module's ``subprocess.run`` / ``subprocess.Popen``
|
||||
themselves — their local patch will override this one.
|
||||
"""
|
||||
noop_run = MagicMock(return_value=MagicMock(returncode=1))
|
||||
noop_popen = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
|
||||
noop_run,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen",
|
||||
noop_popen,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.enforcer.subprocess.run",
|
||||
noop_run,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
|
||||
noop_run,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
|
||||
noop_run,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.Popen",
|
||||
noop_popen,
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
@ -6,7 +6,10 @@ import os
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.steam_backlog_enforcer.game_install import (
|
||||
_assert_not_real_steam,
|
||||
_echo,
|
||||
_ensure_steam_running,
|
||||
_get_real_user,
|
||||
@ -22,7 +25,43 @@ from python_pkg.steam_backlog_enforcer.game_install import (
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PKG = "python_pkg.steam_backlog_enforcer.game_install"
|
||||
|
||||
|
||||
class TestAssertNotRealSteam:
|
||||
"""Tests for the _assert_not_real_steam safety guard."""
|
||||
|
||||
def test_allows_tmp_path(self, tmp_path: Path) -> None:
|
||||
"""Non-Steam paths pass through without raising."""
|
||||
_assert_not_real_steam(tmp_path / "appmanifest_440.acf")
|
||||
|
||||
def test_raises_when_real_steam_not_redirected(self, tmp_path: Path) -> None:
|
||||
"""Raises when path is under real Steam and STEAMAPPS_PATH is real."""
|
||||
real = tmp_path / "real_steam"
|
||||
real.mkdir()
|
||||
fake_manifest = real / "appmanifest_440.acf"
|
||||
fake_manifest.touch()
|
||||
with (
|
||||
patch(f"{PKG}._REAL_STEAMAPPS", real),
|
||||
patch(f"{PKG}.STEAMAPPS_PATH", real),
|
||||
pytest.raises(RuntimeError, match="SAFETY"),
|
||||
):
|
||||
_assert_not_real_steam(fake_manifest)
|
||||
|
||||
def test_allows_when_steamapps_redirected(self, tmp_path: Path) -> None:
|
||||
"""No raise when STEAMAPPS_PATH differs from _REAL_STEAMAPPS."""
|
||||
real = tmp_path / "real_steam"
|
||||
real.mkdir()
|
||||
fake_manifest = real / "appmanifest_440.acf"
|
||||
fake_manifest.touch()
|
||||
redirected = tmp_path / "fake_steam"
|
||||
redirected.mkdir()
|
||||
with (
|
||||
patch(f"{PKG}._REAL_STEAMAPPS", real),
|
||||
patch(f"{PKG}.STEAMAPPS_PATH", redirected),
|
||||
):
|
||||
_assert_not_real_steam(fake_manifest)
|
||||
|
||||
|
||||
class TestEcho:
|
||||
|
||||
@ -345,3 +345,89 @@ class TestPickBestHltbEntry:
|
||||
)
|
||||
assert result is not None
|
||||
assert result[0]["game_name"] == "NEEDY GIRL OVERDOSE"
|
||||
|
||||
def test_exact_match_beats_different_subtitled_game(self) -> None:
|
||||
"""Exact 'Timberman' (26.5 h) must beat 'Timberman: The Big Adventure' (2 h).
|
||||
|
||||
Unlike FAITH where the short name is a demo, here the short name
|
||||
is the real full game and the subtitled entry is a different, shorter
|
||||
game. The exact match should win because it has more hours.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
"game_name": "Timberman",
|
||||
"comp_100": 95400, # 26.5 h
|
||||
}
|
||||
other: dict[str, Any] = {
|
||||
"game_name": "Timberman: The Big Adventure",
|
||||
"comp_100": 7200, # 2 h
|
||||
}
|
||||
timberman_vs: dict[str, Any] = {
|
||||
"game_name": "Timberman VS",
|
||||
"comp_100": 23400, # 6.5 h
|
||||
}
|
||||
result = _pick_best_hltb_entry(
|
||||
"Timberman",
|
||||
[(other, 0.49), (timberman_vs, 0.86), (base, 1.0)],
|
||||
)
|
||||
assert result is not None
|
||||
assert result[0]["game_name"] == "Timberman"
|
||||
|
||||
def test_exact_match_wins_even_when_extended_appears_first(self) -> None:
|
||||
"""Exact match wins regardless of candidate ordering."""
|
||||
base: dict[str, Any] = {
|
||||
"game_name": "Timberman",
|
||||
"comp_100": 95400, # 26.5 h
|
||||
}
|
||||
other: dict[str, Any] = {
|
||||
"game_name": "Timberman: The Big Adventure",
|
||||
"comp_100": 7200, # 2 h
|
||||
}
|
||||
# Extended entry appears first in the list.
|
||||
result = _pick_best_hltb_entry(
|
||||
"Timberman",
|
||||
[(other, 0.49), (base, 1.0)],
|
||||
)
|
||||
assert result is not None
|
||||
assert result[0]["game_name"] == "Timberman"
|
||||
|
||||
def test_exact_only_no_extended(self) -> None:
|
||||
"""Exact match returned when no extended entries exist at all."""
|
||||
exact: dict[str, Any] = {
|
||||
"game_name": "Celeste",
|
||||
"comp_100": 180000, # 50 h
|
||||
}
|
||||
unrelated: dict[str, Any] = {
|
||||
"game_name": "Unrelated Game",
|
||||
"comp_100": 7200,
|
||||
}
|
||||
result = _pick_best_hltb_entry(
|
||||
"Celeste",
|
||||
[(exact, 1.0), (unrelated, 0.6)],
|
||||
)
|
||||
assert result is not None
|
||||
assert result[0]["game_name"] == "Celeste"
|
||||
|
||||
def test_no_exact_no_extended_falls_back(self) -> None:
|
||||
"""When no exact or extended match exists, fall to highest similarity."""
|
||||
a: dict[str, Any] = {"game_name": "FooBar", "comp_100": 3600}
|
||||
b: dict[str, Any] = {"game_name": "FooBaz", "comp_100": 7200}
|
||||
result = _pick_best_hltb_entry("Foo", [(a, 0.7), (b, 0.8)])
|
||||
assert result is not None
|
||||
assert result[0]["game_name"] == "FooBaz"
|
||||
|
||||
def test_extended_only_no_exact(self) -> None:
|
||||
"""Extended entry returned when no exact name match exists."""
|
||||
extended: dict[str, Any] = {
|
||||
"game_name": "Neon: Ultimate Edition",
|
||||
"comp_100": 36000,
|
||||
}
|
||||
unrelated: dict[str, Any] = {
|
||||
"game_name": "Neon Lights",
|
||||
"comp_100": 3600,
|
||||
}
|
||||
result = _pick_best_hltb_entry(
|
||||
"Neon",
|
||||
[(extended, 0.6), (unrelated, 0.7)],
|
||||
)
|
||||
assert result is not None
|
||||
assert result[0]["game_name"] == "Neon: Ultimate Edition"
|
||||
|
||||
@ -20,8 +20,6 @@ from python_pkg.steam_backlog_enforcer.library_hider import (
|
||||
_wait_for_cdp_ready,
|
||||
_wait_for_collections_ready,
|
||||
ensure_steam_debug_port,
|
||||
hide_other_games,
|
||||
unhide_all_games,
|
||||
)
|
||||
|
||||
|
||||
@ -425,85 +423,3 @@ class TestEnsureSteamDebugPort:
|
||||
),
|
||||
):
|
||||
ensure_steam_debug_port()
|
||||
|
||||
|
||||
class TestHideOtherGames:
|
||||
"""Tests for hide_other_games."""
|
||||
|
||||
def test_hides(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
|
||||
return_value={
|
||||
"result": {"result": {"value": '{"totalHidden": 5}'}},
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
|
||||
return_value='{"totalHidden": 5}',
|
||||
),
|
||||
):
|
||||
count = hide_other_games([1, 2, 3], 1)
|
||||
assert count == 5
|
||||
|
||||
def test_empty_list(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
|
||||
return_value={
|
||||
"result": {"result": {"value": '{"totalHidden": 0}'}},
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
|
||||
return_value='{"totalHidden": 0}',
|
||||
),
|
||||
):
|
||||
count = hide_other_games([1], 1)
|
||||
assert count == 0
|
||||
|
||||
def test_no_allowed(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
|
||||
return_value={
|
||||
"result": {"result": {"value": '{"totalHidden": 2}'}},
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
|
||||
return_value='{"totalHidden": 2}',
|
||||
),
|
||||
):
|
||||
count = hide_other_games([1, 2], None)
|
||||
assert count == 2
|
||||
|
||||
|
||||
class TestUnhideAllGames:
|
||||
"""Tests for unhide_all_games."""
|
||||
|
||||
def test_unhides(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
|
||||
return_value={"result": {"result": {"value": '{"count": 10}'}}},
|
||||
),
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
|
||||
return_value='{"count": 10}',
|
||||
),
|
||||
):
|
||||
count = unhide_all_games([1, 2, 3])
|
||||
assert count == 10
|
||||
|
||||
@ -8,7 +8,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.steam_backlog_enforcer.library_hider import (
|
||||
_run_as_user,
|
||||
hide_other_games,
|
||||
restart_steam,
|
||||
unhide_all_games,
|
||||
)
|
||||
|
||||
PKG = "python_pkg.steam_backlog_enforcer.library_hider"
|
||||
@ -116,3 +118,77 @@ class TestRestartSteam:
|
||||
patch(f"{PKG}._wait_for_cdp_ready", return_value=False),
|
||||
):
|
||||
restart_steam()
|
||||
|
||||
|
||||
class TestHideOtherGames:
|
||||
"""Tests for hide_other_games."""
|
||||
|
||||
def test_hides(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.ensure_steam_debug_port"),
|
||||
patch(
|
||||
f"{PKG}._evaluate_js",
|
||||
return_value={
|
||||
"result": {"result": {"value": '{"totalHidden": 5}'}},
|
||||
},
|
||||
),
|
||||
patch(
|
||||
f"{PKG}._cdp_result_value",
|
||||
return_value='{"totalHidden": 5}',
|
||||
),
|
||||
):
|
||||
count = hide_other_games([1, 2, 3], 1)
|
||||
assert count == 5
|
||||
|
||||
def test_empty_list(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.ensure_steam_debug_port"),
|
||||
patch(
|
||||
f"{PKG}._evaluate_js",
|
||||
return_value={
|
||||
"result": {"result": {"value": '{"totalHidden": 0}'}},
|
||||
},
|
||||
),
|
||||
patch(
|
||||
f"{PKG}._cdp_result_value",
|
||||
return_value='{"totalHidden": 0}',
|
||||
),
|
||||
):
|
||||
count = hide_other_games([1], 1)
|
||||
assert count == 0
|
||||
|
||||
def test_no_allowed(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.ensure_steam_debug_port"),
|
||||
patch(
|
||||
f"{PKG}._evaluate_js",
|
||||
return_value={
|
||||
"result": {"result": {"value": '{"totalHidden": 2}'}},
|
||||
},
|
||||
),
|
||||
patch(
|
||||
f"{PKG}._cdp_result_value",
|
||||
return_value='{"totalHidden": 2}',
|
||||
),
|
||||
):
|
||||
count = hide_other_games([1, 2], None)
|
||||
assert count == 2
|
||||
|
||||
|
||||
class TestUnhideAllGames:
|
||||
"""Tests for unhide_all_games."""
|
||||
|
||||
def test_unhides(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.ensure_steam_debug_port"),
|
||||
patch(
|
||||
f"{PKG}._evaluate_js",
|
||||
return_value={"result": {"result": {"value": '{"count": 10}'}}},
|
||||
),
|
||||
patch(
|
||||
f"{PKG}._cdp_result_value",
|
||||
return_value='{"count": 10}',
|
||||
),
|
||||
):
|
||||
count = unhide_all_games([1, 2, 3])
|
||||
assert count == 10
|
||||
|
||||
@ -325,3 +325,25 @@ class TestArgosImportReload:
|
||||
importlib.reload(_helpers)
|
||||
# Restore original module state
|
||||
importlib.reload(_helpers)
|
||||
|
||||
|
||||
class TestTorchImportReload:
|
||||
"""Test import-time torch ImportError branch via reload."""
|
||||
|
||||
def test_torch_import_failure_reload(self) -> None:
|
||||
"""Cover lines 19-20 (except ImportError: torch = None) via reload."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def _block_torch(name: str, *args: object, **kwargs: object) -> object:
|
||||
if name == "torch":
|
||||
msg = "mocked torch unavailable"
|
||||
raise ImportError(msg)
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch.object(builtins, "__import__", side_effect=_block_torch):
|
||||
importlib.reload(_helpers)
|
||||
assert _helpers.torch is None
|
||||
# Restore original module state
|
||||
importlib.reload(_helpers)
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
aiohttp>=3.9
|
||||
beautifulsoup4>=4.0
|
||||
berserk>=0.13
|
||||
bottle>=0.12
|
||||
genanki>=0.13
|
||||
geopandas>=1.0
|
||||
howlongtobeatpy>=1.0
|
||||
lxml>=5.0
|
||||
|
||||
# Optional dependencies for specific scripts (needed for full pylint analysis)
|
||||
|
||||
@ -59,6 +59,8 @@ is_allowed() {
|
||||
|
||||
found=0
|
||||
for file in "$@"; do
|
||||
# Skip files not tracked by git (deleted from index or untracked)
|
||||
git ls-files --error-unmatch "$file" >/dev/null 2>&1 || continue
|
||||
# Check if the file matches a blocked extension
|
||||
if echo "$file" | grep -qiE "$pattern"; then
|
||||
if is_allowed "$file"; then
|
||||
|
||||
330
scripts/disk_cleanup_check.sh
Executable file
330
scripts/disk_cleanup_check.sh
Executable file
@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env bash
|
||||
# disk_cleanup_check.sh — Analyze disk usage and suggest (or perform) cleanup.
|
||||
#
|
||||
# Usage:
|
||||
# ./disk_cleanup_check.sh # Dry-run: report only
|
||||
# ./disk_cleanup_check.sh --clean # Interactive: prompt before each action
|
||||
#
|
||||
# Safe by default: nothing is deleted without --clean AND user confirmation.
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
RESET='\033[0m'
|
||||
|
||||
CLEAN=false
|
||||
if [[ "${1:-}" == "--clean" ]]; then
|
||||
CLEAN=true
|
||||
fi
|
||||
|
||||
TOTAL_RECLAIMABLE=0
|
||||
|
||||
# ───────────────────── helpers ─────────────────────
|
||||
|
||||
human_readable() {
|
||||
local kb=$1
|
||||
if (( kb >= 1048576 )); then
|
||||
printf "%.1f GB" "$(echo "scale=1; $kb / 1048576" | bc)"
|
||||
elif (( kb >= 1024 )); then
|
||||
printf "%.1f MB" "$(echo "scale=1; $kb / 1024" | bc)"
|
||||
else
|
||||
printf "%d KB" "$kb"
|
||||
fi
|
||||
}
|
||||
|
||||
dir_size_kb() {
|
||||
local dir="$1"
|
||||
if [[ -d "$dir" ]]; then
|
||||
du -sk "$dir" 2>/dev/null | awk '{print $1}'
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
}
|
||||
|
||||
report() {
|
||||
local label="$1" size_kb="$2" detail="${3:-}"
|
||||
if (( size_kb > 0 )); then
|
||||
local hr
|
||||
hr=$(human_readable "$size_kb")
|
||||
printf "${YELLOW}%-40s${RESET} %10s" "$label" "$hr"
|
||||
if [[ -n "$detail" ]]; then
|
||||
printf " ${CYAN}(%s)${RESET}" "$detail"
|
||||
fi
|
||||
printf "\n"
|
||||
TOTAL_RECLAIMABLE=$(( TOTAL_RECLAIMABLE + size_kb ))
|
||||
fi
|
||||
}
|
||||
|
||||
# Returns 0 if user confirms, 1 otherwise. Always 1 in dry-run.
|
||||
confirm() {
|
||||
local prompt="$1"
|
||||
if $CLEAN; then
|
||||
printf "${BOLD} → %s [y/N]: ${RESET}" "$prompt"
|
||||
read -r ans
|
||||
if [[ "$ans" =~ ^[Yy]$ ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Safe wrapper: confirm + action, never fails under errexit
|
||||
try_clean() {
|
||||
local prompt="$1"
|
||||
shift
|
||||
if confirm "$prompt"; then
|
||||
"$@"
|
||||
printf "${GREEN} ✓ Done${RESET}\n"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ───────────────────── checks ─────────────────────
|
||||
|
||||
printf "\n${BOLD}=== Disk Cleanup Analysis ===${RESET}\n\n"
|
||||
printf "${BOLD}%-40s %10s${RESET}\n" "Category" "Reclaimable"
|
||||
printf '%s\n' "$(printf '%.0s─' {1..60})"
|
||||
|
||||
# 1. Trash
|
||||
trash_dir="$HOME/.local/share/Trash"
|
||||
size=$(dir_size_kb "$trash_dir")
|
||||
report "Trash" "$size" "empty trash"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Empty trash?" \
|
||||
rm -rf "${trash_dir}/files" "${trash_dir}/info" "${trash_dir}/expunged"
|
||||
fi
|
||||
|
||||
# 2. Pacman cache
|
||||
pacman_cache_size=$(dir_size_kb "/var/cache/pacman/pkg")
|
||||
if (( pacman_cache_size > 0 )); then
|
||||
if command -v paccache &>/dev/null; then
|
||||
pkg_count=$(ls /var/cache/pacman/pkg/ 2>/dev/null | wc -l)
|
||||
report "Pacman cache ($pkg_count pkgs)" "$pacman_cache_size" \
|
||||
"sudo paccache -rk2"
|
||||
try_clean "Clean pacman cache (keep last 2 versions)?" \
|
||||
sudo paccache -rk2
|
||||
else
|
||||
report "Pacman cache (install pacman-contrib)" "$pacman_cache_size" \
|
||||
"pacman -S pacman-contrib && paccache -rk2"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Yay build cache
|
||||
yay_cache="$HOME/.cache/yay"
|
||||
size=$(dir_size_kb "$yay_cache")
|
||||
report "Yay AUR build cache" "$size" "rm -rf ~/.cache/yay"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Clear yay build cache?" rm -rf "${yay_cache:?}"
|
||||
fi
|
||||
|
||||
# 4. Pip cache
|
||||
pip_cache="$HOME/.cache/pip"
|
||||
size=$(dir_size_kb "$pip_cache")
|
||||
report "Pip cache" "$size" "pip cache purge"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Clear pip cache?" rm -rf "${pip_cache:?}"
|
||||
fi
|
||||
|
||||
# 5. uv cache
|
||||
uv_cache="$HOME/.cache/uv"
|
||||
size=$(dir_size_kb "$uv_cache")
|
||||
report "uv cache" "$size" "uv cache clean"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Clear uv cache?" rm -rf "${uv_cache:?}"
|
||||
fi
|
||||
|
||||
# 6. HuggingFace cache
|
||||
hf_cache="$HOME/.cache/huggingface"
|
||||
size=$(dir_size_kb "$hf_cache")
|
||||
report "HuggingFace models" "$size" "manual review recommended"
|
||||
|
||||
# 7. Suno cache
|
||||
suno_cache="$HOME/.cache/suno"
|
||||
size=$(dir_size_kb "$suno_cache")
|
||||
report "Suno AI cache" "$size" "manual review recommended"
|
||||
|
||||
# 8. Go build cache
|
||||
go_cache="$HOME/.cache/go-build"
|
||||
size=$(dir_size_kb "$go_cache")
|
||||
report "Go build cache" "$size" "go clean -cache"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Clear Go build cache?" rm -rf "${go_cache:?}"
|
||||
fi
|
||||
|
||||
# 9. Yarn cache
|
||||
yarn_cache="$HOME/.cache/yarn"
|
||||
size=$(dir_size_kb "$yarn_cache")
|
||||
report "Yarn cache" "$size" "yarn cache clean"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Clear yarn cache?" rm -rf "${yarn_cache:?}"
|
||||
fi
|
||||
|
||||
# 10. npm cache
|
||||
npm_cache="$HOME/.npm/_cacache"
|
||||
size=$(dir_size_kb "$npm_cache")
|
||||
report "npm cache" "$size" "npm cache clean --force"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Clear npm cache?" rm -rf "${npm_cache:?}"
|
||||
fi
|
||||
|
||||
# 11. Electron cache
|
||||
electron_cache="$HOME/.cache/electron"
|
||||
size=$(dir_size_kb "$electron_cache")
|
||||
report "Electron cache" "$size"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Clear Electron cache?" rm -rf "${electron_cache:?}"
|
||||
fi
|
||||
|
||||
# 12. Thumbnails
|
||||
thumb_cache="$HOME/.cache/thumbnails"
|
||||
size=$(dir_size_kb "$thumb_cache")
|
||||
report "Thumbnails cache" "$size"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Clear thumbnails?" rm -rf "${thumb_cache:?}"
|
||||
fi
|
||||
|
||||
# 13. vscode-cpptools cache
|
||||
cpptools_cache="$HOME/.cache/vscode-cpptools"
|
||||
size=$(dir_size_kb "$cpptools_cache")
|
||||
if (( size > 102400 )); then
|
||||
report "VS Code cpptools cache" "$size"
|
||||
try_clean "Clear cpptools cache?" rm -rf "${cpptools_cache:?}"
|
||||
fi
|
||||
|
||||
# 14. Bazel cache
|
||||
bazel_cache="$HOME/.cache/bazel"
|
||||
size=$(dir_size_kb "$bazel_cache")
|
||||
report "Bazel cache" "$size"
|
||||
if (( size > 0 )); then
|
||||
try_clean "Clear bazel cache?" rm -rf "${bazel_cache:?}"
|
||||
fi
|
||||
|
||||
# 15. Browser caches (report only)
|
||||
for browser_cache in \
|
||||
"$HOME/.cache/thorium" \
|
||||
"$HOME/.cache/google-chrome" \
|
||||
"$HOME/.cache/mozilla" \
|
||||
"$HOME/.cache/BraveSoftware"; do
|
||||
if [[ -d "$browser_cache" ]]; then
|
||||
name=$(basename "$browser_cache")
|
||||
size=$(dir_size_kb "$browser_cache")
|
||||
if (( size > 102400 )); then
|
||||
report "Browser cache: $name" "$size" "close browser first"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 16. Docker
|
||||
if command -v docker &>/dev/null; then
|
||||
docker_dir="/var/lib/docker"
|
||||
size=$(dir_size_kb "$docker_dir" || echo 0)
|
||||
if (( size > 1048576 )); then
|
||||
report "Docker (images+build cache)" "$size" "docker system prune -a"
|
||||
try_clean "Docker system prune (removes all unused)?" \
|
||||
docker system prune -af --volumes
|
||||
fi
|
||||
fi
|
||||
|
||||
# 17. Orphan packages
|
||||
orphans=$(pacman -Qdtq 2>/dev/null || true)
|
||||
orphan_count=0
|
||||
if [[ -n "$orphans" ]]; then
|
||||
orphan_count=$(echo "$orphans" | wc -l)
|
||||
fi
|
||||
if (( orphan_count > 0 )); then
|
||||
orphan_size=$(echo "$orphans" | xargs pacman -Qi 2>/dev/null \
|
||||
| awk '/Installed Size/{
|
||||
size=$4; unit=$5;
|
||||
if (unit ~ /GiB/) total += size*1048576;
|
||||
else if (unit ~ /MiB/) total += size*1024;
|
||||
else if (unit ~ /KiB/) total += size;
|
||||
} END{printf "%d", total}' || echo 0)
|
||||
report "Orphan packages ($orphan_count)" "$orphan_size" \
|
||||
"sudo pacman -Rns \$(pacman -Qdtq)"
|
||||
if confirm "Remove $orphan_count orphan packages?"; then
|
||||
# shellcheck disable=SC2046
|
||||
sudo pacman -Rns $(pacman -Qdtq) --noconfirm
|
||||
printf "${GREEN} ✓ Orphans removed${RESET}\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 18. Journal logs — vacuum to 200M
|
||||
journal_line=$(journalctl --disk-usage 2>&1 || true)
|
||||
journal_kb=0
|
||||
if [[ "$journal_line" =~ ([0-9.]+)G ]]; then
|
||||
journal_kb=$(echo "${BASH_REMATCH[1]} * 1048576 / 1" | bc)
|
||||
elif [[ "$journal_line" =~ ([0-9.]+)M ]]; then
|
||||
journal_kb=$(echo "${BASH_REMATCH[1]} * 1024 / 1" | bc)
|
||||
fi
|
||||
if (( journal_kb > 204800 )); then
|
||||
excess=$(( journal_kb - 204800 ))
|
||||
report "Journal logs (excess over 200M)" "$excess" "journalctl --vacuum-size=200M"
|
||||
try_clean "Vacuum journal to 200M?" \
|
||||
sudo journalctl --vacuum-size=200M
|
||||
fi
|
||||
|
||||
# 19. AUR source dirs — old archives (>30 days)
|
||||
aur_dir="$HOME/aur"
|
||||
if [[ -d "$aur_dir" ]]; then
|
||||
old_archives_kb=$(find "$aur_dir" \
|
||||
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
|
||||
-o -name "*.zip" -o -name "*.tar.bz2" \) \
|
||||
-mtime +30 -printf '%k\n' 2>/dev/null \
|
||||
| awk '{t+=$1} END{print t+0}')
|
||||
if (( old_archives_kb > 0 )); then
|
||||
report "AUR old archives (>30d)" "$old_archives_kb" \
|
||||
"find ~/aur '*.pkg.tar.zst' -mtime +30 -delete"
|
||||
if confirm "Delete old AUR archives (>30 days)?"; then
|
||||
find "$aur_dir" \
|
||||
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
|
||||
-o -name "*.zip" -o -name "*.tar.bz2" \) \
|
||||
-mtime +30 -delete
|
||||
printf "${GREEN} ✓ Old AUR archives deleted${RESET}\n"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ───────────────────── report-only items ─────────────────────
|
||||
|
||||
printf "\n${BOLD}--- Report-only (manual review needed) ---${RESET}\n"
|
||||
|
||||
for manual_entry in \
|
||||
"$HOME/Downloads/too_big:~/Downloads/too_big" \
|
||||
"$HOME/Downloads:~/Downloads total" \
|
||||
"$HOME/inne:~/inne" \
|
||||
"/Games:/Games — still playing?"; do
|
||||
dir="${manual_entry%%:*}"
|
||||
label="${manual_entry#*:}"
|
||||
size=$(dir_size_kb "$dir")
|
||||
if (( size > 0 )); then
|
||||
hr=$(human_readable "$size")
|
||||
printf "${RED}%-40s %10s${RESET} ${CYAN}(review manually)${RESET}\n" \
|
||||
"$label" "$hr"
|
||||
fi
|
||||
done
|
||||
|
||||
# ───────────────────── summary ─────────────────────
|
||||
|
||||
printf "\n%s\n" "$(printf '%.0s─' {1..60})"
|
||||
total_hr=$(human_readable "$TOTAL_RECLAIMABLE")
|
||||
printf "${BOLD}Total auto-reclaimable: ${GREEN}%s${RESET}\n" "$total_hr"
|
||||
|
||||
read -r used_kb total_kb used_pct <<< "$(df -k / | awk 'NR==2{print $3, $2, $5}')"
|
||||
used_pct="${used_pct%\%}"
|
||||
printf "${BOLD}Current usage: %s / %s (%s%%)${RESET}\n" \
|
||||
"$(human_readable "$used_kb")" "$(human_readable "$total_kb")" "$used_pct"
|
||||
|
||||
if (( TOTAL_RECLAIMABLE > 0 )); then
|
||||
new_used=$(( used_kb - TOTAL_RECLAIMABLE ))
|
||||
if (( new_used < 0 )); then new_used=0; fi
|
||||
new_pct=$(( new_used * 100 / total_kb ))
|
||||
printf "${BOLD}After cleanup: %s / %s (%d%%)${RESET}\n" \
|
||||
"$(human_readable "$new_used")" "$(human_readable "$total_kb")" "$new_pct"
|
||||
fi
|
||||
|
||||
if ! $CLEAN; then
|
||||
printf "\n${CYAN}Run with --clean to interactively clean each category.${RESET}\n"
|
||||
fi
|
||||
printf "\n"
|
||||
BIN
test_input.wav
BIN
test_input.wav
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user