Compare commits

...
Author SHA1 Message Date
bea a3c750c479 keep local debug info 2026-10-01 23:26:53 +02:00
bea 77497fd9f7 clean up on cmd-q and forced exits 2026-10-01 23:10:41 +02:00
bea b6e6080435 add copyright notice 2026-10-01 22:48:15 +02:00
bea a2e623be70 log at info level 2026-10-01 22:48:15 +02:00
bea cb6fad3fde add the maven wrapper 2026-10-01 22:48:15 +02:00
bea 7982bce49f pin assembly plugin and bump sonar plugin 2026-10-01 22:48:15 +02:00
bea 1b01320c1d register the native hook only once 2026-10-01 22:48:15 +02:00
bea 5dc332e28d give the mouse back when the user returns mid-movement 2026-10-01 22:48:15 +02:00
bea 4a7b4267f5 feed keyboard activity into the away detection 2026-10-01 22:48:15 +02:00
bea 10c31b6834 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	pom.xml
2026-09-29 12:24:47 +02:00
bea e65adc314c open every permission pane the app needs 2026-09-29 12:20:15 +02:00
bea ca99bc0533 let the permissions dialog retry the hook 2026-09-29 12:15:47 +02:00
bea fc526bfed5 enable native access in the packaged runtime 2026-09-29 12:11:08 +02:00
bea 93a64ca1f3 ask for permissions instead of exiting when the hook fails 2026-09-29 12:10:42 +02:00
bea 005f22c581 add jpackage profile to build a macos app 2026-09-29 12:04:06 +02:00
bea 42dc8bbda8 add menu bar tray icon 2026-09-29 12:03:20 +02:00
bea efbd96cd92 use proper swing components 2026-09-29 12:03:04 +02:00
bea f1b4e65998 use system look and feel 2026-09-29 12:02:53 +02:00
bea 4ea3c00916 run movements and clicks on virtual threads 2026-09-29 12:02:42 +02:00
bea 923aa74499 bump log4j to 2.26.1 2026-09-29 12:02:05 +02:00
bea c95e660c2c fix away detection, pause while user is present 2026-09-29 11:58:03 +02:00
bea ed90b246d6 add first unit tests
the ci has always had a 'mvn test' step, but there was nothing to
test. add junit 6 and pin surefire 3.6.0, extract the os detection
into a testable fromOsName method, and cover the movement step math
- including the axis-aligned cases that used to produce infinite
steps and never converge.
2026-09-29 11:51:13 +02:00
bea e551d24bff fix scheduling, threading and movement bugs
scheduling:
- cursor mover created a new java.util.timer for every movement and never
  cancelled it, leaking a live thread roughly every 10 seconds; timers are
  replaced by a single shared scheduledexecutorservice with daemon threads,
  shut down when the automation stops.

clicks:
- the double click sequence used thread.sleep() inside a timer task, blocking
  the scheduler thread; it is now scheduled as one-shot actions on the shared
  scheduler, with the same pacing as before.
- stopping the automation now also cancels in-flight movements and pending
  clicks, instead of letting them run to completion.

movement math:
- when the destination shared one coordinate with the starting position, the
  step on the other axis was computed as length/0 = infinity, and the stop
  condition checked one axis with || instead of &&, so a movement could stop
  (and click) before reaching its destination. steps are now guarded and the
  movement stops only when both axes have converged.

ui and lifecycle:
- the app could not be closed: closing the window only hid it, and ESC
  unregistered the hotkey without exiting. the window now exits cleanly on
  close, stopping the automation and unregistering the native hook.
- toggleRunning() was called from the jnativehook thread and touched swing
  components off the EDT; UI updates are now marshalled to the EDT.
- the hotkey thrashed: key repeats re-fired the toggle while ctrl+alt was
  held, and any other key pressed with the combo held toggled it too. it now
  fires exactly once, when the combo is completed.

away-detection (CursorMoveListener) stays intentionally unscheduled: it cannot
tell the user's movements apart from the app's own, and will be reworked in a
future iteration.
2026-09-29 11:51:06 +02:00
bea f910caf8ef fix broken log4j2 configuration
the file logger appender was removed in a previous change, but its
appenderref was left behind, making log4j2 complain about a missing
FileAppender at every startup.
2026-09-29 11:50:59 +02:00
bea 45cdabae7c bump to jdk 25
replace maven.compiler.source/target with maven.compiler.release,
update ci images to maven:3-eclipse-temurin-25, and fix the jar
name pattern in the readme while at it.
2026-09-29 11:50:51 +02:00
bea 7c0a1cfb04 Merge pull request 'Update dependency org.apache.logging.log4j:log4j-core to v2.24.3' (#3) from renovate/org.apache.logging.log4j-log4j-core-2.x into main
continuous-integration/drone/push Build is passing
Reviewed-on: #3
2025-03-23 12:28:22 +01:00
bea 550446c439 Merge pull request 'Update dependency org.apache.logging.log4j:log4j-api to v2.24.3' (#2) from renovate/org.apache.logging.log4j-log4j-api-2.x into main
continuous-integration/drone/push Build is passing
Reviewed-on: #2
2025-03-23 12:28:14 +01:00
renovate fb9dee3560 Update dependency org.apache.logging.log4j:log4j-core to v2.24.3
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-03-23 11:26:31 +00:00
renovate 5530fb6ae9 Update dependency org.apache.logging.log4j:log4j-api to v2.24.3
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-03-23 11:26:28 +00:00
bea 825e1aa700 Merge pull request 'Configure Renovate' (#1) from renovate/configure into main
continuous-integration/drone/push Build is passing
Reviewed-on: #1
2025-03-23 12:09:53 +01:00
renovate 6c8aa73f67 Add renovate.json
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-03-22 23:09:39 +00:00
bea f90ad4af5c fix deploy!
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2025-03-09 10:40:28 +01:00
bea a93273252a fix deploy (:D)
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2025-03-09 00:18:48 +01:00
bea 56e2d4a6ad fix deploy (...)
continuous-integration/drone/push Build is passing
2025-03-09 00:16:05 +01:00
bea 8606af095e fix promote jdk
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2025-03-08 23:56:55 +01:00
bea 55fdc93557 fix promote
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2025-03-08 23:52:28 +01:00
bea f43f21e768 only run verify ci on push
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2025-03-08 23:47:56 +01:00
bea 4f4fd21be1 fix drone deploy
continuous-integration/drone/push Build was killed
2025-03-08 23:45:48 +01:00
bea f1c846af37 fix drone
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build was killed
2025-03-08 23:40:09 +01:00
bea 9db0d86b31 fix deploy
continuous-integration/drone/push Build encountered an error
2025-03-08 23:39:31 +01:00
bea d57d021749 implement deploy to maven repo
continuous-integration/drone/push Build is failing
2025-03-08 22:21:39 +01:00
bea 93eef9b53f fix code smells
continuous-integration/drone/push Build is passing
2025-03-08 22:10:57 +01:00
bea f5f976bc14 update sonar droneci command
continuous-integration/drone/push Build is passing
2025-03-08 22:06:55 +01:00
bea d6654caafa update pom build version
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is passing
2025-03-08 21:53:53 +01:00
bea a770d3d0b5 update sonar java plugin version
continuous-integration/drone/push Build is failing
2025-03-08 21:48:49 +01:00
bea b002da349b update ci jdk to v21
continuous-integration/drone/push Build is failing
2025-03-08 20:01:06 +01:00
bea 4f9783004e update readme
continuous-integration/drone/push Build is failing
2025-03-08 19:57:35 +01:00
bea a0f959ed2f update readme
continuous-integration/drone/push Build is failing
2025-03-08 19:54:52 +01:00
bea e53dc862b6 remove file logger 2025-02-06 12:22:39 +01:00
22 changed files with 1762 additions and 260 deletions
+41 -7
View File
@@ -1,27 +1,32 @@
kind: pipeline kind: pipeline
name: default name: verify
platform: platform:
os: linux os: linux
arch: arm64 arch: arm64
trigger:
event:
- push
- pull_request
steps: steps:
# test if it compiles correctly # test if it compiles correctly
- name: build - name: build
image: maven:3-eclipse-temurin-17 image: maven:3-eclipse-temurin-25
commands: commands:
- mvn verify --no-transfer-progress -DskipTests=true -Dmaven.javadoc.skip=true -B -V - ./mvnw verify --no-transfer-progress -DskipTests=true -Dmaven.javadoc.skip=true -B -V
# run unit tests # run unit tests
- name: test - name: test
image: maven:3-eclipse-temurin-17 image: maven:3-eclipse-temurin-25
commands: commands:
- mvn test --no-transfer-progress -B -V - ./mvnw test --no-transfer-progress -B -V
# run code analysis # run code analysis
- name: code-analysis - name: code-analysis
image: maven:3-eclipse-temurin-17 image: maven:3-eclipse-temurin-25
commands: commands:
- mvn sonar:sonar --no-transfer-progress -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.host.url=$SONAR_INSTANCE_URL -Dsonar.login=$SONAR_LOGIN_KEY -B -V - ./mvnw sonar:sonar --no-transfer-progress -Dsonar.projectKey=$SONAR_PROJECT_KEY -Dsonar.host.url=$SONAR_INSTANCE_URL -Dsonar.token=$SONAR_LOGIN_KEY -B -V
environment: environment:
SONAR_PROJECT_KEY: SONAR_PROJECT_KEY:
from_secret: sonar_project_key from_secret: sonar_project_key
@@ -29,3 +34,32 @@ steps:
from_secret: sonar_instance_url from_secret: sonar_instance_url
SONAR_LOGIN_KEY: SONAR_LOGIN_KEY:
from_secret: sonar_login_key from_secret: sonar_login_key
---
kind: pipeline
name: deploy
type: docker
platform:
os: linux
arch: arm64
trigger:
event:
- promote
target:
- production
steps:
# skip all previous steps because they were already ran in the "build" phase; we don't need to re-analyze the code.
# upload to maven repository
- name: maven-deploy
image: maven:3-eclipse-temurin-25
commands:
- ./mvnw deploy --no-transfer-progress -DskipTests=true -Dmaven.javadoc.skip=true -B -V -gs settings.xml -Dmaven.repo.username=$MAVEN_REPO_USERNAME -Dmaven.repo.password=$MAVEN_REPO_PASSWORD
environment:
MAVEN_REPO_USERNAME:
from_secret: maven_repo_username
MAVEN_REPO_PASSWORD:
from_secret: maven_repo_password
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2024-2026 Beatrice Dellacà <hello@beatrice.wtf>
All rights reserved.
This software and its source code are the exclusive property of the
copyright holder and are protected by copyright laws and international
treaty provisions. No part of this software or its source code may be
reproduced, distributed, transmitted, displayed, published, or broadcast,
in any form or by any means, electronic or mechanical, including but not
limited to photocopying, recording, or the use of any information storage
and retrieval system, without the prior written permission of the
copyright holder.
No license or other right to use this software or its source code is
granted by this notice, whether by implication, estoppel, or otherwise.
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY
CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN
CONNECTION WITH THIS SOFTWARE OR ITS USE.
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+112 -6
View File
@@ -9,8 +9,7 @@
<version>0.0.5-SNAPSHOT</version> <version>0.0.5-SNAPSHOT</version>
<properties> <properties>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.release>25</maven.compiler.release>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>
@@ -18,13 +17,13 @@
<dependency> <dependency>
<groupId>org.apache.logging.log4j</groupId> <groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId> <artifactId>log4j-api</artifactId>
<version>2.23.1</version> <version>2.26.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.logging.log4j</groupId> <groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId> <artifactId>log4j-core</artifactId>
<version>2.23.1</version> <version>2.26.1</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -32,12 +31,44 @@
<artifactId>jnativehook</artifactId> <artifactId>jnativehook</artifactId>
<version>2.2.2</version> <version>2.2.2</version>
</dependency> </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<distributionManagement>
<repository>
<id>nexus-releases</id>
<name>Nexus Releases Repository</name>
<url>https://nexus.beatrice.wtf/repository/maven-releases/</url>
</repository>
<snapshotRepository>
<id>nexus-snapshots</id>
<name>Nexus Snapshots Repository</name>
<url>https://nexus.beatrice.wtf/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>
<build> <build>
<plugins> <plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
<plugin>
<groupId>org.sonarsource.scanner.maven</groupId>
<artifactId>sonar-maven-plugin</artifactId>
<version>5.8.0.7211</version>
</plugin>
<plugin> <plugin>
<artifactId>maven-assembly-plugin</artifactId> <artifactId>maven-assembly-plugin</artifactId>
<version>3.8.0</version>
<configuration> <configuration>
<archive> <archive>
<manifest> <manifest>
@@ -68,8 +99,8 @@
</repository> </repository>
<repository> <repository>
<id>sonatype-nexus-snapshots</id> <id>sonatype-nexus-releases</id>
<url>https://oss.sonatype.org/content/repositories/snapshots</url> <url>https://oss.sonatype.org/content/repositories/releases</url>
</repository> </repository>
</repositories> </repositories>
@@ -80,4 +111,79 @@
</pluginRepository> </pluginRepository>
</pluginRepositories> </pluginRepositories>
<profiles>
<!-- builds a native .app bundle. jpackage can only target the os it runs on:
run this profile on macos with `mvn clean package -Pmac-app` -->
<profile>
<id>mac-app</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>stage-jpackage-input</id>
<phase>package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/jpackage-input</outputDirectory>
<resources>
<resource>
<directory>${project.build.directory}</directory>
<includes>
<include>${project.build.finalName}-jar-with-dependencies.jar</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.4</version>
<executions>
<execution>
<id>jpackage-app-image</id>
<phase>package</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>${java.home}/bin/jpackage</executable>
<arguments>
<argument>--type</argument>
<argument>app-image</argument>
<argument>--name</argument>
<argument>${project.artifactId}</argument>
<argument>--dest</argument>
<argument>${project.build.directory}/jpackage</argument>
<argument>--input</argument>
<argument>${project.build.directory}/jpackage-input</argument>
<argument>--main-jar</argument>
<argument>${project.build.finalName}-jar-with-dependencies.jar</argument>
<argument>--main-class</argument>
<argument>wtf.beatrice.autosqueal.Main</argument>
<argument>--java-options</argument>
<argument>--enable-native-access=ALL-UNNAMED</argument>
<argument>--mac-package-identifier</argument>
<argument>wtf.beatrice.autosqueal</argument>
<argument>--mac-package-name</argument>
<argument>autosqueal</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project> </project>
+12 -5
View File
@@ -9,16 +9,18 @@
*little java tool to automatically perform mouse actions* *little java tool to automatically perform mouse actions*
*once started, it only acts when you are away: it detects your activity and pauses itself while you are using the machine, so it never fights you for the mouse.*
## supported systems ## supported systems
| system | support | | system | support |
|-----------|------------| |-----------|------------|
| macOS | ✅ **full** | | macOS | ✅ complete |
| Windows | 🟠 limited | | GNU/Linux | ⏳ planned |
| GNU/Linux | 🟠 limited | | Windows | ⏳ planned |
## building ## building
**required tools** **required tools**
- java 17 sdk - java 25 sdk
- git - git
- maven - maven
@@ -26,7 +28,11 @@
1. clone the official repository linked below using `git clone`. 1. clone the official repository linked below using `git clone`.
2. `cd` into the directory and run `mvn clean package`. 2. `cd` into the directory and run `mvn clean package`.
3. you will find a runnable jar with dependencies in the `target/` folder. 3. you will find a runnable jar with dependencies in the `target/` folder.
4. run the built jar file with `java -jar target/autosqueal-*-dependencies.jar`. 4. run the built jar file with `java -jar target/autosqueal-*-jar-with-dependencies.jar`.
**macos .app bundle**
- on macos, run `mvn clean package -Pmac-app`: you will find `autosqueal.app` in `target/jpackage/`.
- the first time you run it, grant it the device control and data access permission (called "accessibility" on older macos versions) and the screen recording one, in system settings, like any other java app.
## support ## support
| category | info | | category | info |
@@ -34,5 +40,6 @@
| official repository | [gitea src](https://git.beatrice.wtf/Tools/autosqeal.git) | | official repository | [gitea src](https://git.beatrice.wtf/Tools/autosqeal.git) |
| mirror repository | [github src](https://github.com/mind-overflow/autosqueal.git) | | mirror repository | [github src](https://github.com/mind-overflow/autosqueal.git) |
| build status | [drone-ci](https://drone.beatrice.wtf/Tools/autosqeal) | | build status | [drone-ci](https://drone.beatrice.wtf/Tools/autosqeal) |
| license | copyright, all rights reserved — see [license](LICENSE) |
| dev email | [hello@beatrice.wtf](mailto:hello@beatrice.wtf) | | dev email | [hello@beatrice.wtf](mailto:hello@beatrice.wtf) |
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0 http://maven.apache.org/xsd/settings-1.1.0.xsd">
<servers>
<server>
<id>nexus-snapshots</id>
<username>${maven.repo.username}</username>
<password>${maven.repo.password}</password>
</server>
<server>
<id>nexus-releases</id>
<username>${maven.repo.username}</username>
<password>${maven.repo.password}</password>
</server>
</servers>
</settings>
@@ -7,30 +7,80 @@ import org.apache.logging.log4j.Logger;
import wtf.beatrice.autosqueal.listener.KeyPressListener; import wtf.beatrice.autosqueal.listener.KeyPressListener;
import wtf.beatrice.autosqueal.ui.MainWindow; import wtf.beatrice.autosqueal.ui.MainWindow;
import javax.swing.*;
import javax.swing.UnsupportedLookAndFeelException;
public class Main { public class Main {
private static final Logger LOGGER = LogManager.getLogger(Main.class); private static final Logger LOGGER = LogManager.getLogger(Main.class);
private static final MainWindow mainWindow = new MainWindow(); private static final MainWindow mainWindow = new MainWindow();
/** Whether the hook and its listener are registered, so retries don't add duplicates. */
private static boolean hookRegistered = false;
public static void main(String[] args) { public static void main(String[] args) {
LOGGER.info("Hello world!"); LOGGER.info("Hello world!");
registerJNativeHook(); // ⌘Q on macOS, the IDE stop button and signals all bypass
mainWindow.init(); // windowClosing: make cleanup run on every exit path instead
Runtime.getRuntime().addShutdownHook(new Thread(mainWindow::cleanup, "autosqueal-shutdown"));
useSystemLookAndFeel();
boolean hookRegistered = registerJNativeHook();
// all Swing components must be created and updated on the EDT
SwingUtilities.invokeLater(() -> {
mainWindow.init();
if (!hookRegistered) {
mainWindow.showPermissionsHelp();
}
});
} }
private static void registerJNativeHook() { private static void useSystemLookAndFeel() {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (ReflectiveOperationException | UnsupportedLookAndFeelException ex) {
LOGGER.warn("Could not set the system look and feel, using the default one", ex);
}
}
public static boolean registerJNativeHook() {
if (hookRegistered) {
return true;
}
LOGGER.info("Registering jnativehook library..."); LOGGER.info("Registering jnativehook library...");
try { try {
GlobalScreen.registerNativeHook(); GlobalScreen.registerNativeHook();
GlobalScreen.addNativeKeyListener(new KeyPressListener()); GlobalScreen.addNativeKeyListener(new KeyPressListener());
hookRegistered = true;
LOGGER.info("Successfully registered jnativehook library!"); LOGGER.info("Successfully registered jnativehook library!");
return true;
} }
catch (NativeHookException ex) { catch (NativeHookException ex) {
LOGGER.error("There was a problem registering the native hook."); LOGGER.error("There was a problem registering the native hook.", ex);
LOGGER.error(ex.getMessage());
System.exit(1); // without the accessibility permission the hotkey cannot be
// registered and the mouse cannot be moved either: the app would
// look alive but do nothing. don't exit: keep the window and the
// tray icon usable, and tell the user what is missing.
return false;
}
}
public static void unregisterJNativeHook() {
if (!hookRegistered) {
return;
}
try {
GlobalScreen.unregisterNativeHook();
LOGGER.info("Successfully unregistered jnativehook library!");
}
catch (NativeHookException ex) {
LOGGER.error("There was a problem unregistering the native hook.");
LOGGER.error(ex.getMessage());
} }
} }
@@ -7,66 +7,80 @@ import wtf.beatrice.autosqueal.util.RunnerUtil;
import java.awt.*; import java.awt.*;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Random; import java.util.Random;
import java.util.Timer; import java.util.concurrent.ExecutorService;
import java.util.TimerTask; import java.util.concurrent.RejectedExecutionException;
import java.util.function.BooleanSupplier;
public class CursorMover extends TimerTask /**
* Periodically starts a new movement of the mouse cursor to a random position,
* or to the top-right corner of the screen for a double click.
*
* The movements only happen when the user is away: while the user is actively
* using the machine, they are skipped, so the app never fights for the mouse.
* The same goes for a movement already in progress: the task checks whether
* the user is still away at every step, and gives the mouse back when they
* are not.
*
* The movement itself is executed by a {@link SingleStepMovementTask}, which
* runs on its own virtual thread until it reaches its destination.
*/
public class CursorMover implements Runnable
{ {
private static final Logger LOGGER = LogManager.getLogger(CursorMover.class); private static final Logger LOGGER = LogManager.getLogger(CursorMover.class);
private final Random random;
private static final int LOOPS_BEFORE_CLICK = 5; private static final int LOOPS_BEFORE_CLICK = 5;
private final Random random = new SecureRandom();
private final ExecutorService movementExecutor;
private final RobotMouseTracker robotTracker;
private final BooleanSupplier isUserAway;
private int iteration = 0; private int iteration = 0;
public CursorMover() { public CursorMover(ExecutorService movementExecutor, RobotMouseTracker robotTracker, BooleanSupplier isUserAway) {
random = new SecureRandom(); this.movementExecutor = movementExecutor;
this.robotTracker = robotTracker;
this.isUserAway = isUserAway;
} }
@Override @Override
public void run() { public void run() {
int currentX = MouseInfo.getPointerInfo().getLocation().x; if (!isUserAway.getAsBoolean()) {
int currentY = MouseInfo.getPointerInfo().getLocation().y; LOGGER.debug("User is present, skipping movement");
return;
}
LOGGER.info("Starting coordinates: {}, {}", currentX, currentY); Point location = MouseInfo.getPointerInfo().getLocation();
LOGGER.info("Starting coordinates: {}, {}", location.x, location.y);
int destX; int destX;
int destY; int destY;
boolean click;
SingleStepMovementTask singleStepMovementTask;
if (iteration == LOOPS_BEFORE_CLICK) { if (iteration == LOOPS_BEFORE_CLICK) {
destX = RunnerUtil.SCREEN_WIDTH - 5; destX = RunnerUtil.SCREEN_WIDTH - 5;
destY = 5; destY = 5;
click = true;
try {
singleStepMovementTask = new SingleStepMovementTask(destX, destY, true);
} catch (AWTException e) {
LOGGER.error(e);
return;
}
iteration = 0; iteration = 0;
} else { } else {
destX = random.nextInt(RunnerUtil.SCREEN_WIDTH); destX = random.nextInt(RunnerUtil.SCREEN_WIDTH);
destY = random.nextInt(RunnerUtil.SCREEN_HEIGHT); destY = random.nextInt(RunnerUtil.SCREEN_HEIGHT);
click = false;
try {
singleStepMovementTask = new SingleStepMovementTask(destX, destY, false);
} catch (AWTException e) {
LOGGER.error(e);
return;
}
iteration++; iteration++;
} }
LOGGER.info("Destination coordinates: {}, {}", destX, destY); LOGGER.info("Destination coordinates: {}, {}", destX, destY);
Timer timer = new Timer(); try {
timer.schedule(singleStepMovementTask, 0L, 2L); SingleStepMovementTask movement = new SingleStepMovementTask(robotTracker, destX, destY, click, isUserAway);
movementExecutor.execute(movement);
} catch (AWTException ex) {
LOGGER.error("Could not start movement task", ex);
} catch (RejectedExecutionException ex) {
LOGGER.debug("Movement not started: automation is shutting down");
}
} }
} }
@@ -0,0 +1,37 @@
package wtf.beatrice.autosqueal.controls;
/**
* Keeps track of the mouse movements the app itself performs, so that other
* components (like the away detection) can tell them apart from the user's.
*
* Written by the movement tasks, read by whoever needs to know.
*/
public class RobotMouseTracker {
private volatile boolean moving = false;
private volatile int lastX = Integer.MIN_VALUE;
private volatile int lastY = Integer.MIN_VALUE;
/** Marks the start of a movement performed by the app. */
public void moveStarted() {
moving = true;
}
/**
* Marks the end of a movement performed by the app, which left the cursor
* at the given coordinates.
*/
public void moveEnded(int x, int y) {
lastX = x;
lastY = y;
moving = false;
}
public boolean isMoving() {
return moving;
}
public boolean isLastPosition(int x, int y) {
return x == lastX && y == lastY;
}
}
@@ -5,113 +5,182 @@ import org.apache.logging.log4j.Logger;
import java.awt.*; import java.awt.*;
import java.awt.event.InputEvent; import java.awt.event.InputEvent;
import java.util.TimerTask; import java.util.function.BooleanSupplier;
public class SingleStepMovementTask extends TimerTask { /**
* Moves the mouse cursor towards a destination one small step at a time.
*
* Runs on its own virtual thread: it mostly sleeps between steps, so blocking
* it is free. When it gets to its destination, it can optionally perform a
* double click, with the pacing the app has always used.
*
* If the user comes back mid-movement, the movement is abandoned at once —
* within the latency of the away detection, which polls about once a second —
* so the mouse is returned to the user instead of being dragged to a corner.
*
* All the movements it performs are reported to the {@link RobotMouseTracker},
* so that the away detection can tell them apart from the user's.
*/
public class SingleStepMovementTask implements Runnable {
private static final Logger LOGGER = LogManager.getLogger(SingleStepMovementTask.class); private static final Logger LOGGER = LogManager.getLogger(SingleStepMovementTask.class);
final int destX; /** Delay between two consecutive cursor steps, in milliseconds. */
final int destY; private static final long STEP_DELAY_MILLISECONDS = 2L;
final Robot robot;
float currentX; private final RobotMouseTracker robotTracker;
float currentY; private final Robot robot;
private final int destX;
private final int destY;
private final boolean click;
private final BooleanSupplier userIsAway;
float stepX = 1; private float currentX;
float stepY = 1; private float currentY;
boolean isRunning = true; private float stepX;
boolean click; private float stepY;
public SingleStepMovementTask(RobotMouseTracker robotTracker, int destinationX, int destinationY, boolean click,
BooleanSupplier userIsAway) throws AWTException {
public SingleStepMovementTask(int destinationX, int destinationY, boolean click) throws AWTException { this.robotTracker = robotTracker;
this.destX = destinationX;
this.destY = destinationY;
this.click = click; this.click = click;
this.userIsAway = userIsAway;
currentX = MouseInfo.getPointerInfo().getLocation().x; Point location = MouseInfo.getPointerInfo().getLocation();
currentY = MouseInfo.getPointerInfo().getLocation().y; this.currentX = location.x;
this.currentY = location.y;
destX = destinationX; int lengthX = Math.round(Math.abs(currentX - destX));
destY = destinationY; int lengthY = Math.round(Math.abs(currentY - destY));
int lengthX; this.stepX = computeStepX(lengthX, lengthY);
int lengthY; this.stepY = computeStepY(lengthX, lengthY);
lengthX = Math.round(Math.abs(currentX - destX));
lengthY = Math.round(Math.abs(currentY - destY));
if(lengthX > lengthY) {
stepX = lengthX / (float) lengthY;
}
if(lengthY > lengthX) {
stepY = lengthY / (float) lengthX;
}
LOGGER.info("Dest: [{}, {}], Curr: [{}, {}]", destX, destY, currentX, currentY); LOGGER.info("Dest: [{}, {}], Curr: [{}, {}]", destX, destY, currentX, currentY);
LOGGER.info("Len: [{}, {}]", lengthX, lengthY);
LOGGER.info("Step: [{}, {}]", stepX, stepY); LOGGER.info("Step: [{}, {}]", stepX, stepY);
robot = new Robot(); this.robot = new Robot();
} }
@Override @Override
public void run() { public void run() {
if(Math.abs(currentX - destX) < 1) {
stepX = currentX - destX;
}
if(Math.abs(currentY - destY) < 1) { // any position change from here on is the app's doing
stepY = currentY - destY; robotTracker.moveStarted();
}
if(destX == Math.round(currentX) || destY == Math.round(currentY)) { try {
while (!hasReachedDestination()) {
if (click) { // a user who came back mid-movement gets the mouse back
try { if (!userIsAway.getAsBoolean()) {
Thread.sleep(500); LOGGER.info("User is back: abandoning the movement at [{}, {}]",
robot.mousePress(InputEvent.BUTTON1_DOWN_MASK); Math.round(currentX), Math.round(currentY));
Thread.sleep(200); robotTracker.moveEnded(Math.round(currentX), Math.round(currentY));
robot.mouseRelease(InputEvent.BUTTON1_DOWN_MASK); return;
Thread.sleep(500);
robot.mousePress(InputEvent.BUTTON1_DOWN_MASK);
Thread.sleep(200);
robot.mouseRelease(InputEvent.BUTTON1_DOWN_MASK);
Thread.sleep(200);
} catch (InterruptedException e) {
LOGGER.error(e);
Thread.currentThread().interrupt();
} }
// when less than a full step is left, move exactly what is
// left, so that both axes always land on their destination
stepX = adjustedStep(currentX, destX, stepX);
stepY = adjustedStep(currentY, destY, stepY);
currentX = advance(currentX, destX, stepX);
currentY = advance(currentY, destY, stepY);
robot.mouseMove(Math.round(currentX), Math.round(currentY));
Thread.sleep(STEP_DELAY_MILLISECONDS);
} }
LOGGER.info("Reached destination, stopping mover timer"); onDestinationReached();
LOGGER.info("Dest: [{}, {}], Curr: [{}, {}]", destX, destY, currentX, currentY); } catch (InterruptedException ex) {
isRunning = false; // the movement was interrupted: the automation was stopped, so
this.cancel(); // just end the movement here
return; Thread.currentThread().interrupt();
LOGGER.debug("Movement interrupted");
robotTracker.moveEnded(Math.round(currentX), Math.round(currentY));
} catch (RuntimeException ex) {
LOGGER.error("Unexpected error while moving the cursor", ex);
robotTracker.moveEnded(Math.round(currentX), Math.round(currentY));
} }
if(currentX > destX) {
currentX -= stepX;
}
if(currentY > destY) {
currentY -= stepY;
}
if(currentX < destX) {
currentX += stepX;
}
if(currentY < destY) {
currentY += stepY;
}
robot.mouseMove(Math.round(currentX), Math.round(currentY));
} }
public boolean isRunning() { private boolean hasReachedDestination() {
return isRunning; return Math.round(currentX) == destX && Math.round(currentY) == destY;
}
private void onDestinationReached() throws InterruptedException {
LOGGER.info("Reached destination [{}, {}], stopping mover", destX, destY);
robotTracker.moveEnded(destX, destY);
if (click) {
performClickSequence();
}
}
/**
* Performs a double click with the same pacing the app has always used:
* press at +500ms, release at +700ms, press at +1200ms, release at +1400ms.
*/
private void performClickSequence() throws InterruptedException {
Thread.sleep(500L);
robot.mousePress(InputEvent.BUTTON1_DOWN_MASK);
Thread.sleep(200L);
robot.mouseRelease(InputEvent.BUTTON1_DOWN_MASK);
Thread.sleep(500L);
robot.mousePress(InputEvent.BUTTON1_DOWN_MASK);
Thread.sleep(200L);
robot.mouseRelease(InputEvent.BUTTON1_DOWN_MASK);
}
/**
* Step to take on the X axis so that both axes reach their destination
* after the same number of steps. Returns 0 when there is no movement to make.
*/
static float computeStepX(int lengthX, int lengthY) {
if (lengthX == 0) {
return 0.0f;
}
if (lengthX >= lengthY) {
return 1.0f;
}
return lengthX / (float) lengthY;
}
/**
* Step to take on the Y axis so that both axes reach their destination
* after the same number of steps. Returns 0 when there is no movement to make.
*/
static float computeStepY(int lengthX, int lengthY) {
if (lengthY == 0) {
return 0.0f;
}
if (lengthY > lengthX) {
return 1.0f;
}
return lengthY / (float) lengthX;
}
/**
* When less than a whole step is left to travel, move exactly what is left
* instead of a whole step, so that the cursor doesn't overshoot the destination.
*/
static float adjustedStep(float current, float destination, float step) {
float remaining = Math.abs(current - destination);
return remaining < 1.0f ? remaining : step;
}
/** Moves current towards destination by step, in the right direction. */
static float advance(float current, float destination, float step) {
if (current > destination) {
return current - step;
}
if (current < destination) {
return current + step;
}
return current;
} }
} }
@@ -2,51 +2,101 @@ package wtf.beatrice.autosqueal.listener;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import wtf.beatrice.autosqueal.controls.RobotMouseTracker;
import java.awt.*; import java.awt.*;
import java.util.TimerTask; import java.util.function.Supplier;
public class CursorMoveListener extends TimerTask { /**
* Watches the cursor position and tracks whether the user is away: if the cursor
private int oldX; * stays still for long enough — ignoring the movements the app performs on its
private int oldY; * own — the user is considered away. Keyboard activity counts as presence,
* too: a user who is typing is using the machine, even if the mouse never moves.
private int loops; */
private boolean isUserAway; public class CursorMoveListener implements Runnable {
private static final Logger LOGGER = LogManager.getLogger(CursorMoveListener.class); private static final Logger LOGGER = LogManager.getLogger(CursorMoveListener.class);
/** Number of consecutive still polls after which the user is considered away. */
private static final int LOOPS_BEFORE_AWAY = 30;
public CursorMoveListener() { private final RobotMouseTracker robotTracker;
oldX = 0; private final Supplier<Point> currentPosition;
oldY = 0;
loops = 0; private int lastSeenX;
private int lastSeenY;
private boolean firstPoll = true;
private int loops = 0;
private volatile boolean userAway = false;
private volatile boolean keyboardActivity = false;
public CursorMoveListener(RobotMouseTracker robotTracker) {
this(robotTracker, () -> MouseInfo.getPointerInfo().getLocation());
}
CursorMoveListener(RobotMouseTracker robotTracker, Supplier<Point> currentPosition) {
this.robotTracker = robotTracker;
this.currentPosition = currentPosition;
}
/**
* Reports that the user pressed a key: keyboard activity counts as
* presence, so it resets the away timer at the next poll. Safe to call
* from any thread.
*/
public void reportKeyboardActivity() {
keyboardActivity = true;
} }
@Override @Override
public void run() { public void run() {
int newX = MouseInfo.getPointerInfo().getLocation().x;
int newY = MouseInfo.getPointerInfo().getLocation().y;
if(newX != oldX || newY != oldY) { // consume the keyboard activity reported since the previous poll
// cursor has been moved boolean keyboardActivity = this.keyboardActivity;
this.keyboardActivity = false;
Point location = currentPosition.get();
if (firstPoll) {
// seed the comparison baseline, so that the first poll is not
// mistaken for a movement
firstPoll = false;
lastSeenX = location.x;
lastSeenY = location.y;
return;
}
boolean positionChanged = location.x != lastSeenX || location.y != lastSeenY;
boolean userMoved = keyboardActivity || (positionChanged && !isAppMovement(location));
if (userMoved) {
if (userAway) {
LOGGER.info("User is no longer away!");
}
loops = 0; loops = 0;
LOGGER.info("User is no longer away!"); } else if (loops < LOOPS_BEFORE_AWAY) {
} else { loops++;
if (loops < 30) { if (loops == LOOPS_BEFORE_AWAY) {
loops++;
} else {
LOGGER.info("User is away!"); LOGGER.info("User is away!");
} }
} }
isUserAway = loops >= 30; userAway = loops >= LOOPS_BEFORE_AWAY;
oldX = newX; lastSeenX = location.x;
oldY = newY; lastSeenY = location.y;
}
/**
* Whether a position change was (most likely) caused by the app itself:
* either a movement is currently in progress, or the cursor is exactly
* where the app last left it.
*/
private boolean isAppMovement(Point location) {
return robotTracker.isMoving() || robotTracker.isLastPosition(location.x, location.y);
} }
public boolean isUserAway() { public boolean isUserAway() {
return isUserAway; return userAway;
} }
} }
@@ -1,7 +1,5 @@
package wtf.beatrice.autosqueal.listener; package wtf.beatrice.autosqueal.listener;
import com.github.kwhat.jnativehook.GlobalScreen;
import com.github.kwhat.jnativehook.NativeHookException;
import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent; import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent;
import com.github.kwhat.jnativehook.keyboard.NativeKeyListener; import com.github.kwhat.jnativehook.keyboard.NativeKeyListener;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -14,44 +12,40 @@ import java.util.List;
public class KeyPressListener implements NativeKeyListener public class KeyPressListener implements NativeKeyListener
{ {
private static final Logger LOGGER = LogManager.getLogger(KeyPressListener.class); private static final Logger LOGGER = LogManager.getLogger(KeyPressListener.class);
private static final List<Integer> pressedKeysIds = new ArrayList<>(); private final List<Integer> pressedKeys = new ArrayList<>();
@Override @Override
public void nativeKeyPressed(NativeKeyEvent e) { public void nativeKeyPressed(NativeKeyEvent e) {
LOGGER.info("Key Pressed: {}", NativeKeyEvent.getKeyText(e.getKeyCode())); // the OS fires repeated "press" events while a key is being held down; ignore them,
// otherwise holding the toggle combo down would rapidly toggle the app on and off
if (e.getKeyCode() == NativeKeyEvent.VC_ESCAPE) { if (pressedKeys.contains(e.getKeyCode())) {
try { return;
GlobalScreen.unregisterNativeHook();
} catch (NativeHookException nativeHookException) {
LOGGER.error("Failed to unregister native hook", nativeHookException);
}
} }
pressedKeysIds.add(e.getKeyCode()); pressedKeys.add(e.getKeyCode());
LOGGER.debug("Key Pressed: {}", NativeKeyEvent.getKeyText(e.getKeyCode()));
handlePressedKeys(); // any key the user presses means they are present: let the away
} // detection know, so the app doesn't grab the mouse while they type
Main.getMainWindow().notifyKeyboardActivity();
@Override // toggle only when the second key of the combo is pressed down: not only does this
public void nativeKeyReleased(NativeKeyEvent e) { // fire exactly once per combo, but it also avoids toggling when any other key is
LOGGER.info("Key Released: {}", NativeKeyEvent.getKeyText(e.getKeyCode())); // pressed or released while ctrl+alt happen to be held down
if ((e.getKeyCode() == NativeKeyEvent.VC_CONTROL && pressedKeys.contains(NativeKeyEvent.VC_ALT))
pressedKeysIds.remove((Integer) e.getKeyCode()); || (e.getKeyCode() == NativeKeyEvent.VC_ALT && pressedKeys.contains(NativeKeyEvent.VC_CONTROL))) {
LOGGER.warn("Received shutdown keystroke: [{}][{}]",
handlePressedKeys(); NativeKeyEvent.getKeyText(NativeKeyEvent.VC_CONTROL),
} NativeKeyEvent.getKeyText(NativeKeyEvent.VC_ALT));
private void handlePressedKeys() {
if (pressedKeysIds.contains(NativeKeyEvent.VC_ALT) &&
pressedKeysIds.contains(NativeKeyEvent.VC_CONTROL)) {
StringBuilder keys = new StringBuilder();
pressedKeysIds.forEach(keyCode -> keys.append("[").append(NativeKeyEvent.getKeyText(keyCode)).append("]"));
LOGGER.warn("Received shutdown keystroke: {}", keys);
Main.getMainWindow().toggleRunning(); Main.getMainWindow().toggleRunning();
} }
} }
@Override
public void nativeKeyReleased(NativeKeyEvent e) {
LOGGER.debug("Key Released: {}", NativeKeyEvent.getKeyText(e.getKeyCode()));
pressedKeys.remove((Integer) e.getKeyCode());
}
} }
@@ -3,15 +3,24 @@ package wtf.beatrice.autosqueal.ui;
import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent; import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import wtf.beatrice.autosqueal.Main;
import wtf.beatrice.autosqueal.controls.CursorMover; import wtf.beatrice.autosqueal.controls.CursorMover;
import wtf.beatrice.autosqueal.controls.RobotMouseTracker;
import wtf.beatrice.autosqueal.listener.CursorMoveListener; import wtf.beatrice.autosqueal.listener.CursorMoveListener;
import wtf.beatrice.autosqueal.util.RunnerUtil; import wtf.beatrice.autosqueal.util.RunnerUtil;
import wtf.beatrice.autosqueal.util.SystemUtil; import wtf.beatrice.autosqueal.util.SystemUtil;
import javax.swing.*; import javax.swing.*;
import java.awt.*; import java.awt.*;
import java.awt.desktop.QuitStrategy;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.util.Timer; import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class MainWindow public class MainWindow
{ {
@@ -20,22 +29,59 @@ public class MainWindow
private static final int WINDOW_HEIGHT = 700; private static final int WINDOW_HEIGHT = 700;
private static final int WINDOW_WIDTH = 800; private static final int WINDOW_WIDTH = 800;
private Timer timerRunner = new Timer(); private static final long AUTOMATION_START_DELAY_SECONDS = 1L;
private CursorMover cursorMover = new CursorMover(); private static final long AWAY_POLL_INTERVAL_SECONDS = 1L;
private Button toggleButton;
private final JFrame frame = new JFrame();
private JButton toggleButton;
// the away detection tracks the user, not the automation: it lives from
// the window's creation and is polled only while the automation runs
private final RobotMouseTracker robotTracker = new RobotMouseTracker();
private final CursorMoveListener awayDetector = new CursorMoveListener(robotTracker);
private ScheduledExecutorService periodicScheduler;
private ExecutorService movementExecutor;
private CursorMover cursorMover;
private TrayIcon trayIcon;
private MenuItem trayToggleItem;
/** Whether cleanup already ran, so it can safely be called twice. */
private boolean cleanedUp = false;
/**
* Builds and shows the main window, and starts the automation.
* Must be called on the EDT.
*/
public void init() { public void init() {
JFrame frame = new JFrame();
frame.setSize(new Dimension(WINDOW_WIDTH, WINDOW_HEIGHT)); frame.setSize(new Dimension(WINDOW_WIDTH, WINDOW_HEIGHT));
frame.setTitle("autosqueal"); frame.setTitle("autosqueal");
frame.setResizable(false); frame.setResizable(false);
frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
cleanup();
}
});
toggleButton = new Button(); // ⌘Q on macOS defaults to calling System.exit(0) directly, which
// bypasses windowClosing and skips cleanup: route quits through
// the window instead, so the hook is always released
if (Desktop.isDesktopSupported()) {
try {
Desktop.getDesktop().setQuitStrategy(QuitStrategy.CLOSE_ALL_WINDOWS);
} catch (UnsupportedOperationException ex) {
LOGGER.debug("Quit strategy not supported here", ex);
}
}
toggleButton = new JButton();
toggleButton.setBounds(new Rectangle((WINDOW_WIDTH / 2) - 60, WINDOW_HEIGHT - 60, 120, 30)); toggleButton.setBounds(new Rectangle((WINDOW_WIDTH / 2) - 60, WINDOW_HEIGHT - 60, 120, 30));
toggleButton.addActionListener(e -> toggleRunning()); toggleButton.addActionListener(e -> toggleRunning());
frame.add(toggleButton); frame.add(toggleButton);
toggleRunning();
int bordersPx = 10; int bordersPx = 10;
int rescaleRateo = ((WINDOW_WIDTH - (2 * bordersPx)) * 100) / RunnerUtil.SCREEN_WIDTH; int rescaleRateo = ((WINDOW_WIDTH - (2 * bordersPx)) * 100) / RunnerUtil.SCREEN_WIDTH;
@@ -50,9 +96,15 @@ public class MainWindow
timestampLabel.setBounds(new Rectangle(bordersPx, bordersPx + rescaleHeight + bordersPx, 100, 30)); timestampLabel.setBounds(new Rectangle(bordersPx, bordersPx + rescaleHeight + bordersPx, 100, 30));
frame.add(timestampLabel); frame.add(timestampLabel);
frame.setLayout(null); frame.setLayout(null);
frame.setVisible(true); frame.setVisible(true);
if (SystemTray.isSupported()) {
addTrayIcon();
}
startAutomation();
updateToggleLabel();
} }
private Image getScreenCapture(int rescaleWidth, int rescaleHeight) { private Image getScreenCapture(int rescaleWidth, int rescaleHeight) {
@@ -82,56 +134,219 @@ public class MainWindow
return null; return null;
} }
public BufferedImage getBufferedImage(Image img) { /**
if (img instanceof BufferedImage image) * Adds a menu bar icon with a toggle entry and a quit entry, so the
{ * automation can be controlled even with the window closed.
return image; */
private void addTrayIcon() {
PopupMenu menu = new PopupMenu();
trayToggleItem = new MenuItem();
trayToggleItem.addActionListener(e -> toggleRunning());
menu.add(trayToggleItem);
MenuItem quitItem = new MenuItem("Quit");
quitItem.addActionListener(e -> {
cleanup();
System.exit(0);
});
menu.add(quitItem);
trayIcon = new TrayIcon(createTrayIconImage(), "autosqueal", menu);
trayIcon.setImageAutoSize(true);
try {
SystemTray.getSystemTray().add(trayIcon);
} catch (AWTException ex) {
LOGGER.error("Could not add the tray icon", ex);
trayIcon = null;
trayToggleItem = null;
} }
// Create a buffered image with transparency
BufferedImage bimage = new BufferedImage(img.getWidth(null), img.getHeight(null), BufferedImage.TYPE_INT_ARGB);
// Draw the image on to the buffered image
Graphics2D bGr = bimage.createGraphics();
bGr.drawImage(img, 0, 0, null);
bGr.dispose();
// Return the buffered image
return bimage;
} }
/** Draws the little mouse pointer used as tray icon. */
private Image createTrayIconImage() {
BufferedImage image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
int[] xPoints = {3, 3, 6, 8, 10, 8, 12};
int[] yPoints = {1, 12, 8, 12, 11, 7, 7};
Graphics2D graphics = image.createGraphics();
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphics.setColor(Color.BLACK);
graphics.fillPolygon(xPoints, yPoints, xPoints.length);
graphics.setColor(Color.WHITE);
graphics.drawPolygon(xPoints, yPoints, xPoints.length);
graphics.dispose();
return image;
}
/**
* Stops the automation and releases everything the app is holding.
* Safe to call more than once, from any thread: window closing and
* JVM shutdown hooks can race each other on the way out.
*/
public synchronized void cleanup() {
if (cleanedUp) {
return;
}
cleanedUp = true;
LOGGER.info("Shutting down...");
stopAutomation();
if (trayIcon != null) {
SystemTray.getSystemTray().remove(trayIcon);
trayIcon = null;
}
Main.unregisterJNativeHook();
}
/**
* Explains which permissions the app needs and offers to open each pane.
* the hook is retried on demand, so the app can start working without a
* restart. must be called on the EDT, after the window is visible.
*/
public void showPermissionsHelp() {
Object[] options = {"Open Device Control Settings", "Open Screen Recording Settings", "Try Again", "Close"};
while (true) {
int choice = JOptionPane.showOptionDialog(frame,
"autosqueal needs three separate permissions to work:\n\n"
+ "1. device control and data access — called \"accessibility\" on older macos\n"
+ " versions — lets the app listen for the ctrl+alt hotkey.\n"
+ " if the pane shows a separate \"events\" switch for autosqueal, turn\n"
+ " that on too: it is what lets the app move the mouse and click.\n\n"
+ "2. screen recording — lets the app take the screenshot shown\n"
+ " in the main window.\n\n"
+ "open each pane, unlock it, and make sure autosqueal is listed and switched on.\n"
+ "if it is already listed, remove it with the minus button and re-add it:\n"
+ "every rebuild looks like a different app to the system.\n"
+ "when you are done, press \"try again\".",
"permissions needed",
JOptionPane.DEFAULT_OPTION,
JOptionPane.WARNING_MESSAGE,
null,
options,
options[0]);
if (choice == 0) {
openPrivacyPane("Privacy_Accessibility");
} else if (choice == 1) {
openPrivacyPane("Privacy_ScreenCapture");
} else if (choice == 2) {
if (Main.registerJNativeHook()) {
LOGGER.info("The native hook was registered on a later attempt!");
return;
}
} else {
// closed or gave up: leave the app running, the window and the
// tray icon still work
return;
}
}
}
/** Opens the privacy & security pane for the given anchor. */
private void openPrivacyPane(String privacyAnchor) {
if (SystemUtil.getHostSystem() != SystemUtil.OperatingSystem.MAC_OS) {
return;
}
try {
new ProcessBuilder("open",
"x-apple.systempreferences:com.apple.preference.security?" + privacyAnchor)
.start();
} catch (IOException ex) {
LOGGER.error("Could not open the system settings", ex);
}
}
/**
* Forwards keyboard activity to the away detection, so that typing
* counts as presence even when the mouse never moves.
*/
public void notifyKeyboardActivity() {
awayDetector.reportKeyboardActivity();
}
/**
* Toggles the automation on or off. Safe to call from any thread: the actual
* work is always marshalled to the EDT, since it touches Swing components.
*/
public void toggleRunning() { public void toggleRunning() {
SwingUtilities.invokeLater(() -> {
if (cursorMover == null) {
startAutomation();
} else {
stopAutomation();
}
updateToggleLabel();
});
}
String label; private void startAutomation() {
LOGGER.info("Starting automation");
if (cursorMover == null) { // periodic work (polling and movement cadence) on a classic scheduler,
timerRunner = new Timer(); // so that it can be scheduled with fixed delays
CursorMoveListener cursorMoveListener = new CursorMoveListener(); periodicScheduler = Executors.newScheduledThreadPool(2, runnable -> {
timerRunner.schedule(cursorMoveListener, 0L, 1000L); Thread thread = new Thread(runnable, "autosqueal-scheduler");
thread.setDaemon(true);
return thread;
});
cursorMover = new CursorMover(); // movements and clicks run on virtual threads: they mostly sleep,
timerRunner.schedule(cursorMover, 1000L, RunnerUtil.SECONDS_BETWEEN_MOVES * 1000L); // and blocking them costs nothing
movementExecutor = Executors.newThreadPerTaskExecutor(
Thread.ofVirtual().name("autosqueal-movement-").factory());
label = "Stop [" + // away-detection: polls the cursor position and ignores the movements
NativeKeyEvent.getKeyText(NativeKeyEvent.VC_CONTROL) + // the app performs itself, as reported by the robot mouse tracker.
"][" + // the automation only moves the mouse when the user is away.
NativeKeyEvent.getKeyText(NativeKeyEvent.VC_ALT) + periodicScheduler.scheduleWithFixedDelay(awayDetector, 0L, AWAY_POLL_INTERVAL_SECONDS, TimeUnit.SECONDS);
"]";
}
else {
timerRunner.cancel();
cursorMover = null; cursorMover = new CursorMover(movementExecutor, robotTracker, awayDetector::isUserAway);
periodicScheduler.scheduleWithFixedDelay(cursorMover,
AUTOMATION_START_DELAY_SECONDS,
RunnerUtil.SECONDS_BETWEEN_MOVES,
TimeUnit.SECONDS);
}
label = "Start [" + private void stopAutomation() {
NativeKeyEvent.getKeyText(NativeKeyEvent.VC_CONTROL) + LOGGER.info("Stopping automation");
"][" +
NativeKeyEvent.getKeyText(NativeKeyEvent.VC_ALT) + if (periodicScheduler != null) {
"]"; periodicScheduler.shutdownNow();
periodicScheduler = null;
} }
toggleButton.setLabel(label); if (movementExecutor != null) {
// interrupts the in-flight movement threads, too
movementExecutor.shutdownNow();
movementExecutor = null;
}
cursorMover = null;
}
private void updateToggleLabel() {
if (toggleButton == null) {
// the window is not initialized yet
return;
}
String hotkey = "[" + NativeKeyEvent.getKeyText(NativeKeyEvent.VC_CONTROL) + "]"
+ "[" + NativeKeyEvent.getKeyText(NativeKeyEvent.VC_ALT) + "]";
String label = (cursorMover == null ? "Start " : "Stop ") + hotkey;
toggleButton.setText(label);
if (trayToggleItem != null) {
trayToggleItem.setLabel(label);
}
} }
} }
@@ -6,14 +6,18 @@ public class SystemUtil
{ {
public static OperatingSystem getHostSystem() { public static OperatingSystem getHostSystem() {
String osName = System.getProperty("os.name").toLowerCase(Locale.ENGLISH); return fromOsName(System.getProperty("os.name"));
if (osName.contains("win")) { }
public static OperatingSystem fromOsName(String osName) {
String normalizedOsName = osName.toLowerCase(Locale.ENGLISH);
if (normalizedOsName.contains("win")) {
return OperatingSystem.WINDOWS; return OperatingSystem.WINDOWS;
} else if (osName.contains("nix") || } else if (normalizedOsName.contains("nix") ||
osName.contains("nux") || normalizedOsName.contains("nux") ||
osName.contains("aix")) { normalizedOsName.contains("aix")) {
return OperatingSystem.LINUX; return OperatingSystem.LINUX;
} else if (osName.contains("mac")) { } else if (normalizedOsName.contains("mac")) {
return OperatingSystem.MAC_OS; return OperatingSystem.MAC_OS;
} else { } else {
return OperatingSystem.UNKNOWN; return OperatingSystem.UNKNOWN;
+1 -5
View File
@@ -1,17 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO"> <Configuration status="WARN">
<Appenders> <Appenders>
<Console name="ConsoleAppender" target="SYSTEM_OUT"> <Console name="ConsoleAppender" target="SYSTEM_OUT">
<PatternLayout pattern="[%d{HH:mm:ss.SSS}] [%t] %-4level | %logger{36} - %msg%n" /> <PatternLayout pattern="[%d{HH:mm:ss.SSS}] [%t] %-4level | %logger{36} - %msg%n" />
</Console> </Console>
<File name="FileAppender" fileName="application-${date:yyyyMMdd}.log" immediateFlush="false" append="true">
<PatternLayout pattern="[%d{yyy-MM-dd HH:mm:ss.SSS}] [%t] %-4level | %logger{36} - %msg%n"/>
</File>
</Appenders> </Appenders>
<Loggers> <Loggers>
<Root level="debug"> <Root level="debug">
<AppenderRef ref="ConsoleAppender" /> <AppenderRef ref="ConsoleAppender" />
<AppenderRef ref="FileAppender"/>
</Root> </Root>
</Loggers> </Loggers>
</Configuration> </Configuration>
@@ -0,0 +1,31 @@
package wtf.beatrice.autosqueal.controls;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RobotMouseTrackerTest
{
@Test
void startsIdle() {
RobotMouseTracker tracker = new RobotMouseTracker();
assertFalse(tracker.isMoving());
assertFalse(tracker.isLastPosition(10, 20));
}
@Test
void tracksOngoingMovement() {
RobotMouseTracker tracker = new RobotMouseTracker();
tracker.moveStarted();
assertTrue(tracker.isMoving());
tracker.moveEnded(15, 25);
assertFalse(tracker.isMoving());
assertTrue(tracker.isLastPosition(15, 25));
assertFalse(tracker.isLastPosition(15, 26));
assertFalse(tracker.isLastPosition(25, 15));
}
}
@@ -0,0 +1,117 @@
package wtf.beatrice.autosqueal.controls;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.fail;
class SingleStepMovementTaskTest
{
private static final int MAX_TICKS = 20_000;
private static final float DELTA = 0.0f;
@Test
void noStepWhenAlreadyAtDestination() {
assertEquals(0.0f, SingleStepMovementTask.computeStepX(0, 0), DELTA);
assertEquals(0.0f, SingleStepMovementTask.computeStepY(0, 0), DELTA);
}
@Test
void axisAlignedMovementsProduceNoInfiniteSteps() {
// moving only horizontally: the y step must be 0, not Infinity or NaN
assertEquals(1.0f, SingleStepMovementTask.computeStepX(100, 0), DELTA);
assertEquals(0.0f, SingleStepMovementTask.computeStepY(100, 0), DELTA);
// moving only vertically: the x step must be 0, not Infinity or NaN
assertEquals(0.0f, SingleStepMovementTask.computeStepX(0, 100), DELTA);
assertEquals(1.0f, SingleStepMovementTask.computeStepY(0, 100), DELTA);
}
@Test
void diagonalMovementsMoveBothAxes() {
assertEquals(1.0f, SingleStepMovementTask.computeStepX(100, 100), DELTA);
assertEquals(1.0f, SingleStepMovementTask.computeStepY(100, 100), DELTA);
assertEquals(0.5f, SingleStepMovementTask.computeStepX(50, 100), DELTA);
assertEquals(1.0f, SingleStepMovementTask.computeStepY(50, 100), DELTA);
assertEquals(1.0f, SingleStepMovementTask.computeStepX(100, 50), DELTA);
assertEquals(0.5f, SingleStepMovementTask.computeStepY(100, 50), DELTA);
}
@Test
void stepsAreNeverInfiniteOrNaN() {
for (int lengthX = 0; lengthX <= 400; lengthX += 7) {
for (int lengthY = 0; lengthY <= 400; lengthY += 7) {
float stepX = SingleStepMovementTask.computeStepX(lengthX, lengthY);
float stepY = SingleStepMovementTask.computeStepY(lengthX, lengthY);
assertFalse(Float.isInfinite(stepX), "infinite stepX for lengths [" + lengthX + ", " + lengthY + "]");
assertFalse(Float.isNaN(stepX), "NaN stepX for lengths [" + lengthX + ", " + lengthY + "]");
assertFalse(Float.isInfinite(stepY), "infinite stepY for lengths [" + lengthX + ", " + lengthY + "]");
assertFalse(Float.isNaN(stepY), "NaN stepY for lengths [" + lengthX + ", " + lengthY + "]");
}
}
}
@Test
void advanceMovesTowardTheDestination() {
assertEquals(5.0f, SingleStepMovementTask.advance(5.0f, 5.0f, 1.0f), DELTA); // already there
assertEquals(9.0f, SingleStepMovementTask.advance(10.0f, 5.0f, 1.0f), DELTA); // decreasing
assertEquals(6.0f, SingleStepMovementTask.advance(5.0f, 10.0f, 1.0f), DELTA); // increasing
}
@Test
void adjustedStepUsesExactRemainderWhenClose() {
assertEquals(0.4f, SingleStepMovementTask.adjustedStep(10.0f, 10.4f, 1.0f), 0.00001f);
assertEquals(0.4f, SingleStepMovementTask.adjustedStep(10.8f, 10.4f, 1.0f), 0.00001f);
assertEquals(1.0f, SingleStepMovementTask.adjustedStep(10.0f, 20.0f, 1.0f), DELTA);
}
@Test
void movementConvergesToDestination() {
record MovementCase(float startX, float startY, int destX, int destY) {}
MovementCase[] cases = {
new MovementCase(100, 100, 500, 500), // plain diagonal
new MovementCase(500, 500, 100, 100), // going up-left
new MovementCase(100, 100, 500, 100), // horizontal (used to break: infinite step)
new MovementCase(500, 100, 100, 100), // horizontal, other direction
new MovementCase(100, 100, 100, 500), // vertical (used to break: infinite step)
new MovementCase(100, 500, 100, 100), // vertical, other direction
new MovementCase(105, 105, 105, 105), // already at destination
new MovementCase(0, 0, 2000, 1000), // steep diagonal
new MovementCase(2000, 1000, 0, 0), // same, backwards
new MovementCase(37, 173, 2311, 997), // random-ish, both directions mixed
};
for (MovementCase move : cases) {
float x = move.startX();
float y = move.startY();
int lengthX = Math.round(Math.abs(x - move.destX()));
int lengthY = Math.round(Math.abs(y - move.destY()));
float stepX = SingleStepMovementTask.computeStepX(lengthX, lengthY);
float stepY = SingleStepMovementTask.computeStepY(lengthX, lengthY);
boolean converged = false;
for (int tick = 0; tick < MAX_TICKS; tick++) {
if (Math.round(x) == move.destX() && Math.round(y) == move.destY()) {
converged = true;
break;
}
stepX = SingleStepMovementTask.adjustedStep(x, move.destX(), stepX);
stepY = SingleStepMovementTask.adjustedStep(y, move.destY(), stepY);
x = SingleStepMovementTask.advance(x, move.destX(), stepX);
y = SingleStepMovementTask.advance(y, move.destY(), stepY);
}
if (!converged) {
fail("movement did not converge: stuck at [" + Math.round(x) + ", " + Math.round(y)
+ "] while heading to [" + move.destX() + ", " + move.destY() + "]");
}
}
}
}
@@ -0,0 +1,214 @@
package wtf.beatrice.autosqueal.listener;
import org.junit.jupiter.api.Test;
import wtf.beatrice.autosqueal.controls.RobotMouseTracker;
import java.awt.*;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CursorMoveListenerTest
{
private static final int LOOPS_BEFORE_AWAY = 30;
private final RobotMouseTracker robotTracker = new RobotMouseTracker();
/** Builds a listener that reads scripted cursor positions, one per poll. */
private CursorMoveListener scriptedListener(Point... positions) {
Queue<Point> positionQueue = new ArrayDeque<>(java.util.List.of(positions));
return new CursorMoveListener(robotTracker, positionQueue::remove);
}
private static Point at(int x, int y) {
return new Point(x, y);
}
@Test
void userIsAwayAfterThirtyStillSeconds() {
Point[] still = new Point[LOOPS_BEFORE_AWAY + 2];
for (int i = 0; i < still.length; i++) {
still[i] = at(100, 100);
}
CursorMoveListener listener = scriptedListener(still);
// the first poll seeds the baseline and does not count
for (int i = 0; i < LOOPS_BEFORE_AWAY; i++) {
listener.run();
assertFalse(listener.isUserAway());
}
listener.run();
assertTrue(listener.isUserAway());
}
@Test
void firstPollDoesNotCountAsMovement() {
// the very first position read must seed the baseline: starting anywhere
// else would have counted as a user movement and reset the away timer
CursorMoveListener listener = scriptedListener(at(500, 300), at(500, 300));
listener.run();
listener.run();
assertFalse(listener.isUserAway());
// still at (500, 300), so the baseline was seeded correctly
}
@Test
void userMovementResetsTheAwayTimer() {
List<Point> positions = new ArrayList<>();
positions.add(at(100, 100)); // baseline
for (int i = 0; i < 5; i++) positions.add(at(100, 100)); // 5 still polls
positions.add(at(140, 100)); // the user moves the mouse
for (int i = 0; i < LOOPS_BEFORE_AWAY; i++) positions.add(at(140, 100)); // then it stays put
CursorMoveListener listener = scriptedListener(positions.toArray(Point[]::new));
listener.run(); // seed the baseline
for (int i = 0; i < 5; i++) listener.run(); // loops = 5
assertFalse(listener.isUserAway());
listener.run(); // the user moved: the away timer resets
assertFalse(listener.isUserAway());
// ...and it still takes 30 more still polls to be away again
for (int i = 0; i < LOOPS_BEFORE_AWAY - 1; i++) listener.run(); // loops = 29
assertFalse(listener.isUserAway());
listener.run(); // loops = 30
assertTrue(listener.isUserAway());
}
@Test
void appMovementsDoNotKeepTheUserPresent() {
// this is the regression the away detection was disconnected for: the
// app moves the mouse every few seconds, and before the fix those
// movements were indistinguishable from the user's, so the app would
// always think the user was present
// every poll finds the cursor where the app last left it
List<Point> positions = new ArrayList<>();
positions.add(at(100, 100)); // baseline
for (int i = 1; i <= 40; i++) positions.add(at(100 + i, 100));
CursorMoveListener listener = scriptedListener(positions.toArray(Point[]::new));
listener.run(); // seed the baseline
for (int i = 1; i <= 40; i++) {
// the app just finished a movement, leaving the cursor exactly
// where this poll will find it
robotTracker.moveEnded(100 + i, 100);
listener.run();
}
// every position change was the app's own doing: the user is away
assertTrue(listener.isUserAway());
}
@Test
void ongoingAppMovementsDoNotKeepTheUserPresent() {
// same as above, but the polls happen while the app is mid-movement,
// with the cursor transiently at intermediate positions
Point[] positions = new Point[41];
positions[0] = at(100, 100);
for (int i = 1; i < positions.length; i++) {
positions[i] = at(100 + (i / 2), 100);
}
CursorMoveListener listener = scriptedListener(positions);
robotTracker.moveStarted();
for (int i = 1; i < positions.length; i++) {
listener.run();
}
assertTrue(listener.isUserAway());
}
@Test
void keyboardActivityResetsTheAwayTimer() {
List<Point> positions = new ArrayList<>();
positions.add(at(100, 100)); // baseline
for (int i = 0; i < 29; i++) positions.add(at(100, 100)); // 29 still polls
positions.add(at(100, 100)); // the user presses a key
for (int i = 0; i < LOOPS_BEFORE_AWAY; i++) positions.add(at(100, 100)); // then the mouse stays put
CursorMoveListener listener = scriptedListener(positions.toArray(Point[]::new));
listener.run(); // seed the baseline
for (int i = 0; i < 29; i++) listener.run(); // loops = 29
assertFalse(listener.isUserAway());
// the user is typing without moving the mouse: the timer resets
listener.reportKeyboardActivity();
listener.run();
assertFalse(listener.isUserAway());
// ...and it still takes 30 still polls to be away again
for (int i = 0; i < LOOPS_BEFORE_AWAY - 1; i++) listener.run(); // loops = 29
assertFalse(listener.isUserAway());
listener.run(); // loops = 30
assertTrue(listener.isUserAway());
}
@Test
void keyboardActivityBringsTheUserBackWhileAway() {
Point[] positions = new Point[LOOPS_BEFORE_AWAY + 3];
for (int i = 0; i < positions.length; i++) {
positions[i] = at(100, 100);
}
CursorMoveListener listener = scriptedListener(positions);
for (int i = 0; i <= LOOPS_BEFORE_AWAY; i++) { // baseline + 30 still polls
listener.run();
}
assertTrue(listener.isUserAway());
// the user comes back at the keyboard: the away state must end,
// even though the mouse never moved
listener.reportKeyboardActivity();
listener.run();
assertFalse(listener.isUserAway());
}
@Test
void userComingBackIsDetectedWhileTheAppIsWiggling() {
// the app keeps wiggling while the user is away; when the user comes
// back and moves the mouse, the away state must end
// polls: baseline, 30 still polls, two app wiggles, then the user's move
Point[] positions = new Point[LOOPS_BEFORE_AWAY + 4];
for (int i = 0; i <= LOOPS_BEFORE_AWAY; i++) {
positions[i] = at(100, 100);
}
positions[LOOPS_BEFORE_AWAY + 1] = at(150, 100);
positions[LOOPS_BEFORE_AWAY + 2] = at(200, 100);
positions[LOOPS_BEFORE_AWAY + 3] = at(333, 100);
CursorMoveListener listener = scriptedListener(positions);
// baseline + 30 still polls: the user goes away
for (int i = 0; i <= LOOPS_BEFORE_AWAY; i++) {
listener.run();
}
assertTrue(listener.isUserAway());
// the app wiggles: each movement is discounted, the user stays "away"
robotTracker.moveEnded(150, 100);
listener.run();
assertTrue(listener.isUserAway());
robotTracker.moveEnded(200, 100);
listener.run();
assertTrue(listener.isUserAway());
// the user comes back and grabs the mouse
listener.run();
assertFalse(listener.isUserAway());
}
}
@@ -0,0 +1,34 @@
package wtf.beatrice.autosqueal.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SystemUtilTest
{
@Test
void detectsWindows() {
assertEquals(SystemUtil.OperatingSystem.WINDOWS, SystemUtil.fromOsName("Windows 11"));
}
@Test
void detectsLinux() {
assertEquals(SystemUtil.OperatingSystem.LINUX, SystemUtil.fromOsName("Linux"));
assertEquals(SystemUtil.OperatingSystem.LINUX, SystemUtil.fromOsName("GNU/Linux"));
}
@Test
void detectsMacOS() {
assertEquals(SystemUtil.OperatingSystem.MAC_OS, SystemUtil.fromOsName("Mac OS X"));
}
@Test
void unknownForOtherSystems() {
assertEquals(SystemUtil.OperatingSystem.UNKNOWN, SystemUtil.fromOsName("SunOS"));
}
@Test
void hostSystemMatchesOsName() {
assertEquals(SystemUtil.fromOsName(System.getProperty("os.name")), SystemUtil.getHostSystem());
}
}