Skip to content

Instantly share code, notes, and snippets.

@panayotoff
Last active October 3, 2025 09:37
Show Gist options
  • Save panayotoff/8dc9c2cee22428cfa291c061c3b4c05f to your computer and use it in GitHub Desktop.
Save panayotoff/8dc9c2cee22428cfa291c061c3b4c05f to your computer and use it in GitHub Desktop.
Mac OSX software mouse jiggler
// 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