Last active
October 3, 2025 09:37
-
-
Save panayotoff/8dc9c2cee22428cfa291c061c3b4c05f to your computer and use it in GitHub Desktop.
Mac OSX software mouse jiggler
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// jiggler.jxa | |
// Natural mouse jiggler for macOS (JXA) with duration + user-interrupt + startup circle | |
// Created by Panayotoff — 2025-09-19 | |
// | |
// Quick run: | |
// osascript -l JavaScript jiggler.jxa | |
// | |
// Run for a fixed time (examples): | |
// osascript -l JavaScript jiggler.jxa -- 30m | |
// osascript -l JavaScript jiggler.jxa -- 2h | |
// osascript -l JavaScript jiggler.jxa -- --minutes 45 | |
// osascript -l JavaScript jiggler.jxa -- --hours 3 | |
// osascript -l JavaScript jiggler.jxa -- 2h --debug | |
// | |
// Background: | |
// nohup osascript -l JavaScript ~/jiggler.jxa -- 2h >/tmp/jiggler.log 2>&1 & | |
// Stop later: | |
// pkill -f "osascript.*jiggler.jxa" | |
// | |
// Notes: | |
// • First run: grant System Settings → Privacy & Security → Accessibility (for "osascript"). | |
// • Valid duration: 30m–8h (clamped). No args → run indefinitely. | |
// • The script aborts any ongoing movement if it detects YOU moving the mouse. | |
ObjC.import('CoreGraphics'); | |
ObjC.import('Foundation'); // for argument access | |
ObjC.import('stdlib'); // for exit() | |
let DEBUG = false; | |
// ----- Config ----- | |
const INTERVAL_MIN = 160; // seconds (≈ 2:40) | |
const INTERVAL_MAX = 220; // seconds (≈ 3:40) | |
const GLIDE_DUR_MIN = 4.0; // seconds the visible movement lasts | |
const GLIDE_DUR_MAX = 9.0; | |
const SEGMENTS_MIN = 2; | |
const SEGMENTS_MAX = 4; | |
const RADIUS_MIN = 8; | |
const RADIUS_MAX = 28; | |
// Timing | |
const STEP_DELAY = 0.02; // seconds between micro-steps (~50 FPS) | |
const IDLE_CHECK_INTERVAL = 0.05; // seconds between idle samples | |
const IDLE_REQUIRED_MS = 1200; // how long to see near-zero motion to consider "idle" | |
const USER_MOVE_THRESHOLD = 3; // px delta from our last post that indicates user input | |
// Duration bounds | |
const MIN_DURATION_S = 1 * 60; // 1 minute | |
const MAX_DURATION_S = 24 * 60 * 60; // 24 hours | |
// Global end timestamp | |
let GLOBAL_END_TS = Number.POSITIVE_INFINITY; // Keep 'let' as it's generally safer for reassignable globals | |
// ----- Helpers ----- | |
function rand(min, max) { return min + Math.random() * (max - min); } | |
function randi(min, max) { return Math.floor(rand(min, max + 1)); } | |
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } | |
function now() { return Date.now() / 1000; } | |
function sleep(s) { delay(s); } | |
function isTimeExpired() { | |
return now() >= GLOBAL_END_TS; | |
} | |
function getMouse() { | |
const ev = $.CGEventCreate(null); | |
const p = $.CGEventGetLocation(ev); | |
return { x: p.x, y: p.y }; | |
} | |
function postMove(x, y) { | |
const pt = $.CGPointMake(x, y); | |
const e = $.CGEventCreateMouseEvent(null, $.kCGEventMouseMoved, pt, $.kCGMouseButtonLeft); | |
$.CGEventPost($.kCGHIDEventTap, e); | |
} | |
function dist(a, b) { | |
const dx = a.x - b.x, dy = a.y - b.y; | |
return Math.hypot(dx, dy); | |
} | |
function easeInOutSine(t) { return -(Math.cos(Math.PI * t) - 1) / 2; } | |
function lerp(a, b, t) { return a + (b - a) * t; } | |
function quadBezier(ax, ay, cx, cy, bx, by, t) { | |
const u = 1 - t; | |
const x = u*u*ax + 2*u*t*cx + t*t*bx; | |
const y = u*u*ay + 2*u*t*cy + t*t*by; | |
return { x, y }; | |
} | |
function randomOffset(radius, biasX = 0, biasY = 0) { | |
const ang = rand(0, 2*Math.PI); | |
const r = rand(radius * 0.2, radius); | |
const bx = biasX * rand(0.2, 0.6); | |
const by = biasY * rand(0.2, 0.6); | |
return { x: Math.cos(ang) * r + bx, y: Math.sin(ang) * r + by }; | |
} | |
// ----- CLI duration parsing ----- | |
function getCliArgs() { | |
const args = $.NSProcessInfo.processInfo.arguments; | |
const count = args.count; | |
const a = []; | |
for (let i = 0; i < count; i++) { | |
a.push(ObjC.unwrap(args.objectAtIndex(i))); | |
} | |
if (a.includes("--debug")) { | |
DEBUG = true; | |
const idx = a.indexOf("--debug"); | |
a.splice(idx, 1); | |
} | |
if (DEBUG) console.log(`DEBUG: Raw process arguments: [${a.join(', ')}]`); | |
const dd = a.indexOf("--"); | |
if (dd >= 0) return a.slice(dd + 1); | |
const jxaIdx = a.findIndex(s => String(s).toLowerCase().endsWith(".jxa")); | |
return jxaIdx >= 0 ? a.slice(jxaIdx + 1) : []; | |
} | |
function parseDurationSeconds() { | |
const args = getCliArgs(); | |
if (DEBUG) console.log(`DEBUG: Parsed arguments: [${args.join(', ')}]`); | |
let sec = null; | |
let skip = false; | |
for (let i = 0; i < args.length; i++) { | |
if (skip) { | |
skip = false; | |
continue; | |
} | |
const tok = args[i]; | |
if (tok === "--minutes" && i + 1 < args.length) { | |
const n = parseFloat(args[i + 1]); | |
if (!isNaN(n)) { | |
sec = (sec || 0) + n * 60; | |
if (DEBUG) console.log(`DEBUG: Found --minutes ${n}, total seconds: ${sec}`); | |
skip = true; | |
} | |
continue; | |
} | |
if (tok === "--hours" && i + 1 < args.length) { | |
const n = parseFloat(args[i + 1]); | |
if (!isNaN(n)) { | |
sec = (sec || 0) + n * 3600; | |
if (DEBUG) console.log(`DEBUG: Found --hours ${n}, total seconds: ${sec}`); | |
skip = true; | |
} | |
continue; | |
} | |
const m = tok.match(/^(\d+(?:\.\d+)?)([hm])$/i); | |
if (m) { | |
const n = parseFloat(m[1]); | |
const unit = m[2].toLowerCase(); | |
sec = (sec || 0) + (unit === "h" ? n * 3600 : n * 60); | |
if (DEBUG) console.log(`DEBUG: Found ${tok}, parsed as ${n}${unit}, total seconds: ${sec}`); | |
} | |
} | |
if (sec == null) { | |
if (DEBUG) console.log(`DEBUG: No duration found, returning null`); | |
return null; | |
} | |
const clamped = clamp(sec, MIN_DURATION_S, MAX_DURATION_S); | |
if (DEBUG) console.log(`DEBUG: Duration ${sec}s clamped to ${clamped}s (min: ${MIN_DURATION_S}, max: ${MAX_DURATION_S})`); | |
return clamped; | |
} | |
// ----- User movement detection / idle wait ----- | |
// REVISED userInterrupted function with try-catch | |
function userInterrupted(lastPosted) { | |
let cur; | |
try { | |
cur = getMouse(); | |
} catch (e) { | |
console.error(`ERROR: getMouse() failed during userInterrupted check: ${e.toString()}`); | |
// If getting mouse position fails due to an error, assume user moved and abort. | |
return true; | |
} | |
return dist(cur, lastPosted) > USER_MOVE_THRESHOLD; | |
} | |
// Wait until the mouse is "idle" (minimal motion) for IDLE_REQUIRED_MS | |
// Now checks for time expiry and returns early if time runs out | |
function waitForIdle() { | |
let targetIdleEnd = Date.now() + IDLE_REQUIRED_MS; // <— let, not const | |
let last = getMouse(); | |
while (Date.now() < targetIdleEnd) { | |
if (isTimeExpired()) return; | |
sleep(IDLE_CHECK_INTERVAL); | |
const cur = getMouse(); | |
if (dist(cur, last) > 1) { | |
last = cur; | |
targetIdleEnd = Date.now() + IDLE_REQUIRED_MS; // now valid | |
} | |
} | |
} | |
// ----- Movement primitives that can be interrupted ----- | |
function glideToPoints(points, totalDurationSec) { | |
// points: [{x,y}, ...] (includes start as first element) | |
// Returns true if completed, false if aborted by user movement or time expiry | |
const perSeg = totalDurationSec / Math.max(1, (points.length - 1)); | |
let lastPosted = getMouse(); | |
for (let s = 0; s < points.length - 1; s++) { | |
// Check time expiry at segment start | |
if (isTimeExpired()) return false; | |
const A = points[s], B = points[s + 1]; | |
const mx = (A.x + B.x) / 2, my = (A.y + B.y) / 2; | |
const dx = B.x - A.x, dy = B.y - A.y; | |
const len = Math.sqrt(dx*dx + dy*dy) || 1; | |
const nx = -dy / len, ny = dx / len; | |
const curve = rand(0.3, 0.8); | |
const cMag = clamp(len * 0.25 * curve, 2, 24); | |
const side = Math.random() < 0.5 ? 1 : -1; | |
const Cx = mx + nx * cMag * side, Cy = my + ny * cMag * side; | |
const segStart = now(); | |
const segDur = perSeg * rand(0.9, 1.2); | |
while (true) { | |
// Check time expiry | |
if (isTimeExpired()) return false; | |
// If the user moved the mouse since our last post, abort | |
if (userInterrupted(lastPosted)) return false; | |
const t = (now() - segStart) / segDur; | |
if (t >= 1) break; | |
const tt = easeInOutSine(t); | |
const p = quadBezier(A.x, A.y, Cx, Cy, B.x, B.y, tt); | |
postMove(p.x, p.y); | |
lastPosted = { x: p.x, y: p.y }; | |
sleep(STEP_DELAY); | |
} | |
// land exactly on B | |
postMove(B.x, B.y); | |
lastPosted = { x: B.x, y: B.y }; | |
// tiny pause between segments (also abort-opportunity) | |
if (isTimeExpired() || userInterrupted(lastPosted)) return false; | |
sleep(rand(0.04, 0.12)); | |
} | |
return true; | |
} | |
// A natural multi-segment glide near current position (interruptible) | |
function naturalGlide() { | |
if (isTimeExpired()) return false; | |
const start = getMouse(); | |
const glideDur = rand(GLIDE_DUR_MIN, GLIDE_DUR_MAX); | |
const segments = randi(SEGMENTS_MIN, SEGMENTS_MAX); | |
const radius = rand(RADIUS_MIN, RADIUS_MAX); | |
const points = [{ x: start.x, y: start.y }]; | |
for (let i = 0; i < segments; i++) { | |
const last = points[points.length - 1]; | |
const biasX = start.x - last.x, biasY = start.y - last.y; | |
const off = randomOffset(radius, biasX * 0.15, biasY * 0.15); | |
points.push({ x: last.x + off.x, y: last.y + off.y }); | |
} | |
const endNear = randomOffset(Math.max(2, radius * 0.25)); | |
points.push({ x: start.x + endNear.x, y: start.y + endNear.y }); | |
const finished = glideToPoints(points, glideDur); | |
// Small snap-back if we fully finished and drifted | |
if (finished && !isTimeExpired() && Math.random() < 0.6) { | |
const cur = getMouse(); | |
const snap = { x: lerp(cur.x, start.x, rand(0.6, 0.9)), | |
y: lerp(cur.y, start.y, rand(0.6, 0.9)) }; | |
// Only snap if user hasn't moved | |
if (!userInterrupted(cur)) postMove(snap.x, snap.y); | |
} | |
return finished; | |
} | |
// REVISED startupCircle to use a flag and break | |
function startupCircle(durationSec = 2.0, radius = 25) { | |
const start = getMouse(); | |
const steps = Math.max(20, Math.floor(durationSec / STEP_DELAY)); | |
let lastPosted = start; | |
let interrupted = false; // New flag | |
// Circle around start | |
for (let i = 0; i <= steps; i++) { | |
if (isTimeExpired()) { | |
interrupted = true; | |
break; | |
} | |
// Check for user interruption | |
if (userInterrupted(lastPosted)) { | |
if (DEBUG) console.log("DEBUG: User interrupted during startup circle."); | |
interrupted = true; // Set the flag | |
break; // Exit the loop | |
} | |
const t = i / steps; // 0..1 | |
const ang = t * 2 * Math.PI; | |
const x = start.x + Math.cos(ang) * radius; | |
const y = start.y + Math.sin(ang) * radius; | |
postMove(x, y); | |
lastPosted = { x, y }; | |
sleep(STEP_DELAY); | |
} | |
// If the loop was interrupted, skip the return to start and indicate failure. | |
if (interrupted) { | |
return false; // Circle was not completed | |
} | |
// Original return to start smoothly (also interruptible) | |
const backPoints = [ lastPosted, { x: start.x, y: start.y } ]; | |
const backOk = glideToPoints(backPoints, 0.6); | |
return backOk; | |
} | |
function nextIntervalSeconds() { return rand(INTERVAL_MIN, INTERVAL_MAX); } | |
// ----- Main ----- | |
(function main() { | |
const dur = parseDurationSeconds(); // null → run forever | |
const startTs = now(); | |
// Set global end timestamp | |
GLOBAL_END_TS = dur == null ? Number.POSITIVE_INFINITY : startTs + dur; | |
// Log the duration if specified | |
if (dur != null) { | |
const mins = Math.floor(dur / 60); | |
const hrs = Math.floor(mins / 60); | |
const remMins = mins % 60; | |
const endDate = new Date(GLOBAL_END_TS * 1000); | |
const endTime = endDate.toTimeString().substring(0, 5); // HH:MM format | |
const startTime = new Date(startTs * 1000).toTimeString().substring(0, 5); | |
console.log(`[${startTime}] Jiggler started. Will run for ${hrs > 0 ? hrs + 'h ' : ''}${remMins}m and stop at ${endTime}.`); | |
} else { | |
const startTime = new Date(startTs * 1000).toTimeString().substring(0, 5); | |
console.log(`[${startTime}] Jiggler started (no duration limit).`); | |
} | |
// Tiny randomized delay before first action | |
sleep(rand(0.8, 2.0)); | |
if (isTimeExpired()) { $.exit(0); } | |
// Do the startup circle (2s). If user moves, we stop immediately and skip the return. | |
const circleDone = startupCircle(2.0, 25); | |
if (isTimeExpired()) { $.exit(0); } | |
if (!circleDone) { | |
// If user interrupted, wait for idle before proceeding. | |
waitForIdle(); | |
if (isTimeExpired()) { $.exit(0); } | |
} | |
// Another small delay so we don't act immediately after the circle | |
sleep(rand(3, 12)); | |
if (isTimeExpired()) { $.exit(0); } | |
// Main loop until duration ends / indefinitely | |
while (!isTimeExpired()) { | |
// If user starts moving before we start a glide, wait for idle | |
waitForIdle(); | |
if (isTimeExpired()) break; | |
// Try a glide; if user interrupts, abort and wait for idle again | |
const finished = naturalGlide(); | |
if (isTimeExpired()) break; | |
if (!finished) { | |
waitForIdle(); | |
if (isTimeExpired()) break; | |
} | |
const remaining = GLOBAL_END_TS - now(); | |
if (remaining <= 0) break; | |
const wait = nextIntervalSeconds(); | |
const actualWait = dur == null ? wait : Math.min(wait, remaining); | |
// Sleep in small chunks to check expiry more frequently | |
const checkInterval = 1.0; // Check every second | |
let slept = 0; | |
while (slept < actualWait && !isTimeExpired()) { | |
const napTime = Math.min(checkInterval, actualWait - slept); | |
sleep(napTime); | |
slept += napTime; | |
} | |
if (isTimeExpired()) break; | |
} | |
// Exit cleanly when done | |
console.log("Jiggler duration expired. Exiting."); | |
$.exit(0); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment