#!/bin/bash
# =============================================================================
#  DeskLink control helper for macOS - double-click to start.
#  Nothing to install: uses only what comes with macOS.
#  Keep this window open while you want this Mac to be controllable
#  (Ctrl+C or close the window to stop).
#  First time only: System Settings > Privacy & Security > Accessibility >
#  turn on Terminal, then start this again.
#  Test that control works:  bash ~/Downloads/DeskLink-Helper.command --test
# =============================================================================
if [ ! -x /usr/bin/perl ]; then
  echo "This Mac is missing Perl, which the DeskLink helper needs."
  read -n 1 -s -r -p "Press any key to close..."; exit 1
fi

# Unpack the two parts of the helper into a private temporary folder, then run it.
DL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/desklink.XXXXXX")" || exit 1
trap 'kill $(jobs -p) 2>/dev/null; rm -rf "$DL_DIR"' EXIT

# Keep the display awake while the helper runs. macOS only starts the screen saver (and its
# password lock) when display sleep is NOT blocked, so this stops the idle lock that ends sharing.
caffeinate -d -i -w $$ &
echo "[ok] Keeping the display awake so the screen saver can't lock this Mac while sharing"

cat > "$DL_DIR/input.js" <<'DESKLINK_INPUT_JS'
// DeskLink input driver for macOS (JavaScript for Automation).
// Reads one JSON command per line on stdin and turns it into real mouse/keyboard events.
// Started by the helper; needs Accessibility permission for the app that launched it (e.g. Terminal).
ObjC.import("Cocoa");
ObjC.import("ApplicationServices");

let INVERT_SCROLL = false;
try {
  INVERT_SCROLL = ObjC.unwrap($.NSProcessInfo.processInfo.environment.objectForKey("DESKLINK_INVERT_SCROLL")) === "1";
} catch (e) {}

// Browser KeyboardEvent.code -> macOS virtual key code
const KEYS = {
  KeyA: 0, KeyS: 1, KeyD: 2, KeyF: 3, KeyH: 4, KeyG: 5, KeyZ: 6, KeyX: 7, KeyC: 8, KeyV: 9, IntlBackslash: 10,
  KeyB: 11, KeyQ: 12, KeyW: 13, KeyE: 14, KeyR: 15, KeyY: 16, KeyT: 17, Digit1: 18, Digit2: 19, Digit3: 20,
  Digit4: 21, Digit6: 22, Digit5: 23, Equal: 24, Digit9: 25, Digit7: 26, Minus: 27, Digit8: 28, Digit0: 29,
  BracketRight: 30, KeyO: 31, KeyU: 32, BracketLeft: 33, KeyI: 34, KeyP: 35, Enter: 36, KeyL: 37, KeyJ: 38,
  Quote: 39, KeyK: 40, Semicolon: 41, Backslash: 42, Comma: 43, Slash: 44, KeyN: 45, KeyM: 46, Period: 47,
  Tab: 48, Space: 49, Backquote: 50, Backspace: 51, Escape: 53, MetaRight: 54, MetaLeft: 55, OSRight: 54, OSLeft: 55,
  ShiftLeft: 56, CapsLock: 57, AltLeft: 58, ControlLeft: 59, ShiftRight: 60, AltRight: 61, ControlRight: 62,
  F17: 64, NumpadDecimal: 65, NumpadMultiply: 67, NumpadAdd: 69, NumLock: 71, AudioVolumeUp: 72,
  AudioVolumeDown: 73, AudioVolumeMute: 74, NumpadDivide: 75, NumpadEnter: 76, NumpadSubtract: 78, F18: 79,
  F19: 80, NumpadEqual: 81, Numpad0: 82, Numpad1: 83, Numpad2: 84, Numpad3: 85, Numpad4: 86, Numpad5: 87,
  Numpad6: 88, Numpad7: 89, F20: 90, Numpad8: 91, Numpad9: 92, F5: 96, F6: 97, F7: 98, F3: 99, F8: 100,
  F9: 101, F11: 103, F13: 105, F16: 106, F14: 107, F10: 109, F12: 111, F15: 113, Help: 114, Insert: 114,
  Home: 115, PageUp: 116, Delete: 117, F4: 118, End: 119, F2: 120, PageDown: 121, F1: 122,
  ArrowLeft: 123, ArrowRight: 124, ArrowDown: 125, ArrowUp: 126,
};

// Modifier keys -> CGEventFlags bits
const MOD = {
  ShiftLeft: 0x20000, ShiftRight: 0x20000, ControlLeft: 0x40000, ControlRight: 0x40000,
  AltLeft: 0x80000, AltRight: 0x80000, MetaLeft: 0x100000, MetaRight: 0x100000, OSLeft: 0x100000, OSRight: 0x100000,
};

const MOUSE = {
  // browser button -> [cgButton, downType, upType, dragType]
  0: [0, 1, 2, 6],
  2: [1, 3, 4, 7],
  1: [2, 25, 26, 27],
};

const HID_TAP = 0;
const CLICK_STATE_FIELD = 1;

let held = {}; // modifier code -> true
let flags = 0;
let buttonsDown = {};
let last = { x: 0, y: 0 };
let lastDown = { t: 0, b: -1, x: 0, y: 0, n: 0 };
let bounds = null, boundsAt = 0;

function screenBounds() {
  const now = Date.now();
  if (!bounds || now - boundsAt > 2000) {
    const r = $.CGDisplayBounds($.CGMainDisplayID());
    bounds = { x: r.origin.x, y: r.origin.y, w: r.size.width, h: r.size.height };
    boundsAt = now;
  }
  return bounds;
}

function toScreen(cmd) {
  if (typeof cmd.x !== "number" || typeof cmd.y !== "number") return last;
  const b = screenBounds();
  last = { x: b.x + cmd.x * (b.w - 1), y: b.y + cmd.y * (b.h - 1) };
  return last;
}

function post(ev) {
  $.CGEventSetFlags(ev, flags);
  $.CGEventPost(HID_TAP, ev);
}

function mouseEvent(type, p, cgButton, clicks) {
  const ev = $.CGEventCreateMouseEvent(null, type, { x: p.x, y: p.y }, cgButton);
  if (clicks) $.CGEventSetIntegerValueField(ev, CLICK_STATE_FIELD, clicks);
  post(ev);
}

function handle(cmd) {
  switch (cmd.t) {
    case "move": {
      const p = toScreen(cmd);
      const dragging = Object.keys(buttonsDown);
      if (dragging.length) {
        const m = MOUSE[dragging[0]];
        mouseEvent(m[3], p, m[0], 0);
      } else {
        mouseEvent(5, p, 0, 0);
      }
      break;
    }
    case "down": {
      const m = MOUSE[cmd.b];
      if (!m) break;
      const p = toScreen(cmd);
      const now = Date.now();
      const near = Math.abs(p.x - lastDown.x) < 6 && Math.abs(p.y - lastDown.y) < 6;
      const n = now - lastDown.t < 400 && lastDown.b === cmd.b && near ? Math.min(lastDown.n + 1, 3) : 1;
      lastDown = { t: now, b: cmd.b, x: p.x, y: p.y, n };
      buttonsDown[cmd.b] = true;
      mouseEvent(m[1], p, m[0], n);
      break;
    }
    case "up": {
      const m = MOUSE[cmd.b];
      if (!m) break;
      const p = toScreen(cmd);
      delete buttonsDown[cmd.b];
      mouseEvent(m[2], p, m[0], lastDown.b === cmd.b ? lastDown.n : 1);
      break;
    }
    case "wheel": {
      let dy = -Math.round(cmd.dy || 0), dx = -Math.round(cmd.dx || 0);
      if (INVERT_SCROLL) { dy = -dy; dx = -dx; }
      if (!dx && !dy) break;
      let ev;
      try {
        ev = $.CGEventCreateScrollWheelEvent2(null, 0 /* pixels */, 2, dy, dx, 0);
      } catch (e) {
        ev = $.CGEventCreateScrollWheelEvent(null, 0, 2, dy, dx);
      }
      post(ev);
      break;
    }
    case "key": {
      const code = KEYS[cmd.code];
      if (code === undefined) break;
      const mod = MOD[cmd.code];
      if (mod) {
        if (cmd.down) held[cmd.code] = true;
        else delete held[cmd.code];
        flags = 0;
        for (const k in held) flags |= MOD[k];
      }
      post($.CGEventCreateKeyboardEvent(null, code, !!cmd.down));
      break;
    }
    case "reset": {
      for (const b in buttonsDown) {
        const m = MOUSE[b];
        mouseEvent(m[2], last, m[0], 1);
      }
      buttonsDown = {};
      for (const k in held) post($.CGEventCreateKeyboardEvent(null, KEYS[k], false));
      held = {};
      flags = 0;
      break;
    }
  }
}

function run() {
  const b = screenBounds();
  console.log(`input-ready ${b.w}x${b.h} accessibility=${$.AXIsProcessTrusted() ? "yes" : "no"}`);
  const stdin = $.NSFileHandle.fileHandleWithStandardInput;
  let buf = "";
  while (true) {
    const data = stdin.availableData;
    if (data.length === 0) break; // parent closed the pipe
    buf += $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js;
    let i;
    while ((i = buf.indexOf("\n")) >= 0) {
      const line = buf.slice(0, i);
      buf = buf.slice(i + 1);
      if (!line) continue;
      try {
        handle(JSON.parse(line));
      } catch (e) {
        console.log("input-error " + e);
      }
    }
  }
}
DESKLINK_INPUT_JS

cat > "$DL_DIR/helper.pl" <<'DESKLINK_HELPER_PL'
#!/usr/bin/perl
# DeskLink control helper (macOS) - uses only what ships with macOS (perl + osascript).
# Runs a small WebSocket server on 127.0.0.1:47800 that only the DeskLink website may connect to,
# and passes validated mouse/keyboard commands to input.js (JavaScript for Automation), which performs them.
# Options: --test  (move the pointer in a small circle for 3 seconds, then quit)
#          --origin https://your-site.netlify.app   --port N   --driver /path/to/input.js
use strict;
use warnings;
use IO::Socket::INET;
use IO::Select;
use IO::Handle;
use IPC::Open3;
use Symbol qw(gensym);
use Digest::SHA qw(sha1);
use MIME::Base64 qw(encode_base64);
use JSON::PP ();
use Time::HiRes qw(time sleep);
use POSIX ();

$| = 1;
$SIG{PIPE} = 'IGNORE';

my %opt = (port => 47800, origin => '', test => 0, driver => '');
for (my $i = 0; $i < @ARGV; $i++) {
    my $a = $ARGV[$i];
    if    ($a eq '--test')   { $opt{test} = 1 }
    elsif ($a eq '--origin') { $opt{origin} = $ARGV[++$i] // '' }
    elsif ($a eq '--port')   { $opt{port} = $ARGV[++$i] // 47800 }
    elsif ($a eq '--driver') { $opt{driver} = $ARGV[++$i] // '' }
}
my %ORIGINS = map { $_ => 1 } ('https://desklink.ca', 'https://www.desklink.ca', 'https://desklink-micah.netlify.app');
if ($opt{origin} ne '') { (my $o = $opt{origin}) =~ s{/$}{}; $ORIGINS{$o} = 1 }
my $DRIVER = $opt{driver} || do { (my $d = $0) =~ s{[^/]*$}{}; ($d || './') . 'input.js' };
my $GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
my $json = JSON::PP->new->canonical;

# ---------------- input driver process ----------------
my ($drv_pid, $drv_in, $drv_err, $drv_buf, $drv_restart_at, $on_ready) = (0, undef, undef, '', 0, undef);
my $sel = IO::Select->new;

sub start_driver {
    $drv_err = gensym;
    $drv_pid = eval { open3($drv_in, '>&STDOUT', $drv_err, 'osascript', '-l', 'JavaScript', $DRIVER) };
    if (!$drv_pid) { print "Could not start the input driver: $@\n"; $drv_restart_at = time + 3; return }
    $drv_in->autoflush(1);
    $drv_buf = '';
    $sel->add($drv_err);
}

sub driver_line {
    my ($line) = @_;
    if ($line =~ /accessibility=no/) {
        print "\n!! Accessibility permission is missing, so clicks and typing will be ignored.\n";
        print "   Open System Settings > Privacy & Security > Accessibility and turn on the app you ran this from (e.g. Terminal), then restart the helper.\n\n";
    }
    if ($line =~ /^input-ready (\S+)/) {
        print "[ok] Input driver ready (main display $1)\n";
        if ($on_ready) { my $f = $on_ready; $on_ready = undef; $f->() }
    } elsif ($line !~ /accessibility=no/) {
        print "[input] $line\n";
    }
}

sub read_driver {
    my $n = sysread($drv_err, my $chunk, 4096);
    if (!$n) {
        $sel->remove($drv_err);
        close $drv_err; close $drv_in;
        waitpid($drv_pid, 0);
        print "Input driver stopped; restarting...\n";
        $drv_restart_at = time + 1;
        return;
    }
    $drv_buf .= $chunk;
    while ($drv_buf =~ s/^(.*?)\r?\n//) { driver_line($1) if length $1 }
}

sub drive {
    my ($cmd) = @_;
    return unless $drv_in && $drv_pid;
    print {$drv_in} $json->encode($cmd), "\n";
}

# ---------------- command validation ----------------
sub num {
    my ($v) = @_;
    return undef if !defined $v || ref $v;
    return undef unless $v =~ /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;
    return $v + 0;
}
sub c01 { my $v = num($_[0]); return undef unless defined $v; return $v < 0 ? 0 : $v > 1 ? 1 : $v }
sub cint { my ($v, $l) = @_; $v = num($v); return 0 unless defined $v; $v = POSIX::floor($v + 0.5); return $v < -$l ? -$l : $v > $l ? $l : $v }

sub sanitize {
    my $m = eval { $json->decode($_[0]) };
    return undef unless ref $m eq 'HASH' && defined $m->{t} && !ref $m->{t};
    my $t = $m->{t};
    if ($t eq 'move') {
        my ($x, $y) = (c01($m->{x}), c01($m->{y}));
        return defined $x && defined $y ? { t => 'move', x => $x, y => $y } : undef;
    }
    if ($t eq 'down' || $t eq 'up') {
        my $b = num($m->{b});
        return undef unless defined $b && ($b == 0 || $b == 1 || $b == 2);
        my $o = { t => $t, b => int $b };
        my ($x, $y) = (c01($m->{x}), c01($m->{y}));
        if (defined $x && defined $y) { $o->{x} = $x; $o->{y} = $y }
        return $o;
    }
    return { t => 'wheel', dx => cint($m->{dx}, 2000), dy => cint($m->{dy}, 2000) } if $t eq 'wheel';
    if ($t eq 'key') {
        my $c = $m->{code};
        return undef unless defined $c && !ref $c && $c =~ /^[A-Za-z0-9]{1,24}$/;
        return { t => 'key', code => $c, down => ($m->{down} ? JSON::PP::true : JSON::PP::false) };
    }
    return { t => 'reset' } if $t eq 'reset';
    return undef;
}

# ---------------- minimal HTTP + WebSocket server (RFC 6455, text frames only) ----------------
my %conns;          # fileno -> { sock, buf, ws, close_at }
my $client;         # the connection currently allowed to control (a %conns entry)

sub frame_text {
    my ($p) = @_;
    my $l = length $p;
    return ($l < 126 ? pack('CC', 0x81, $l) : pack('CCn', 0x81, 126, $l)) . $p;
}

sub drop_conn {
    my ($c) = @_;
    return unless $c && $c->{sock};
    my $s = $c->{sock};
    $sel->remove($s);
    delete $conns{fileno($s) // -1};
    close $s;
    $c->{sock} = undef;
    if ($client && $client == $c) {
        $client = undef;
        drive({ t => 'reset' });
        print "Sharing stopped (website disconnected).\n";
    }
}

sub send_raw { my ($c, $d) = @_; return unless $c->{sock}; syswrite($c->{sock}, $d) }

sub handle_http {
    my ($c) = @_;
    my $end = index($c->{buf}, "\r\n\r\n");
    if ($end < 0) { drop_conn($c) if length($c->{buf}) > 8192; return }
    my $head = substr($c->{buf}, 0, $end);
    substr($c->{buf}, 0, $end + 4, '');
    my ($first, @lines) = split /\r\n/, $head;
    my ($method) = split / /, ($first // '');
    my %h;
    for (@lines) { $h{lc $1} = $2 if /^([^:]+):\s*(.*?)\s*$/ }
    my $origin = $h{origin};

    if (($h{upgrade} // '') =~ /^websocket$/i) {
        my $key = $h{'sec-websocket-key'};
        if (!defined $origin || !$ORIGINS{$origin} || !$key) {
            print 'Refused connection from origin: ', ($origin // '(none)'), "\n";
            send_raw($c, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
            drop_conn($c);
            return;
        }
        my $accept = encode_base64(sha1($key . $GUID), '');
        send_raw($c, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: $accept\r\n\r\n");
        $c->{ws} = 1;
        if ($client && $client != $c) {
            # One sharing page at a time. Tell the old page it was replaced (code 4001) so it stops retrying.
            my $old = $client;
            $client = undef;
            send_raw($old, pack('CCn', 0x88, 2 + 8, 4001) . 'replaced');
            $old->{close_at} = time + 0.5;
            print "Another DeskLink page took over control.\n";
        }
        $client = $c;
        send_raw($c, frame_text('{"hello":"desklink-helper","v":1}'));
        print "[ok] DeskLink is sharing this Mac - remote control ready.\n";
        handle_ws($c) if length $c->{buf};
        return;
    }

    # Plain HTTP answer lets DeskLink pages check that the helper is running without taking it over.
    my $options = ($method // '') eq 'OPTIONS';
    my $body = $options ? '' : "DeskLink helper is running.\n";
    my $r = ($options ? "HTTP/1.1 204 No Content\r\n" : "HTTP/1.1 200 OK\r\n")
          . "Content-Type: text/plain\r\nCache-Control: no-store\r\nConnection: close\r\n";
    if (defined $origin && $ORIGINS{$origin}) {
        $r .= "Access-Control-Allow-Origin: $origin\r\nAccess-Control-Allow-Private-Network: true\r\n";
    }
    $r .= 'Content-Length: ' . length($body) . "\r\n\r\n" . $body;
    send_raw($c, $r);
    drop_conn($c);
}

sub handle_ws {
    my ($c) = @_;
    while ($c->{sock} && length($c->{buf}) >= 2) {
        my ($b0, $b1) = unpack('CC', $c->{buf});
        my $op = $b0 & 0x0f;
        my $masked = $b1 & 0x80;
        my $len = $b1 & 0x7f;
        my $off = 2;
        if ($len == 126) {
            return if length($c->{buf}) < 4;
            $len = unpack('n', substr($c->{buf}, 2, 2));
            $off = 4;
        } elsif ($len == 127) { drop_conn($c); return }   # never needed for tiny input messages
        if (!$masked || $len > 4096) { drop_conn($c); return }   # browsers always mask
        return if length($c->{buf}) < $off + 4 + $len;
        my $mask = substr($c->{buf}, $off, 4);
        my $data = substr($c->{buf}, $off + 4, $len);
        substr($c->{buf}, 0, $off + 4 + $len, '');
        $data = $data ^ substr($mask x (int($len / 4) + 1), 0, $len) if $len;
        next if $c->{close_at};                       # replaced page: ignore anything it still sends
        if ($op == 0x1) {
            my $cmd = sanitize($data);
            drive($cmd) if $cmd && $client && $client == $c;
        } elsif ($op == 0x8) {
            send_raw($c, pack('CC', 0x88, 0));
            drop_conn($c);
            return;
        } elsif ($op == 0x9 && $len <= 125) {
            send_raw($c, pack('CC', 0x8a, $len) . $data);
        }
    }
}

# ---------------- main ----------------
# Stop the input driver together with the helper (Ctrl+C, closing the window, or being killed).
sub stop_all { kill 'TERM', $drv_pid if $drv_pid; exit 0 }
$SIG{INT} = $SIG{TERM} = $SIG{HUP} = \&stop_all;
END { kill 'TERM', $drv_pid if $drv_pid }

start_driver();

if ($opt{test}) {
    print "Test mode: once the input driver is ready, the pointer will trace a small circle around the middle of your main screen for 3 seconds.\n";
    my $done = 0;
    $on_ready = sub {
        my $a = 0;
        my $end = time + 3;
        while (time < $end) {
            $a += 0.2;
            drive({ t => 'move', x => 0.5 + 0.08 * cos($a), y => 0.5 + 0.08 * sin($a) });
            sleep 0.03;
        }
        print "Test done. If the pointer moved, control works.\n";
        $done = 1;
    };
    my $give_up = time + 20;
    while (!$done && time < $give_up) {
        for my $h ($sel->can_read(0.5)) { read_driver() if $h == $drv_err }
    }
    print "The input driver didn't start within 20 seconds.\n" unless $done;
    close $drv_in if $drv_in;
    exit($done ? 0 : 1);
}

my $listener = IO::Socket::INET->new(
    LocalAddr => '127.0.0.1', LocalPort => $opt{port}, Proto => 'tcp', Listen => 16, ReuseAddr => 1,
) or do {
    print "Port $opt{port} is already in use. Is another DeskLink helper window already open?\n";
    exit 1;
};
$sel->add($listener);
print "DeskLink helper listening on 127.0.0.1:$opt{port}\n";
print 'Accepting the website at: ', join(', ', sort keys %ORIGINS), "\n";
print "Keep this window open while you want this Mac to be controllable. Press Ctrl+C to stop.\n";

while (1) {
    for my $h ($sel->can_read(0.25)) {
        if ($h == $listener) {
            my $s = $listener->accept or next;
            $s->autoflush(1);
            $s->blocking(1);
            setsockopt($s, 6, 1, 1);   # TCP_NODELAY
            $conns{fileno $s} = { sock => $s, buf => '', ws => 0 };
            $sel->add($s);
        } elsif ($drv_err && $h == $drv_err) {
            read_driver();
        } else {
            my $c = $conns{fileno $h} or do { $sel->remove($h); next };
            my $n = sysread($h, my $chunk, 65536);
            if (!$n) { drop_conn($c); next }
            $c->{buf} .= $chunk;
            $c->{ws} ? handle_ws($c) : handle_http($c);
        }
    }
    my $now = time;
    for my $c (values %conns) { drop_conn($c) if $c->{close_at} && $now >= $c->{close_at} }
    if ($drv_restart_at && $now >= $drv_restart_at) { $drv_restart_at = 0; start_driver() }
}
DESKLINK_HELPER_PL

/usr/bin/perl "$DL_DIR/helper.pl" --driver "$DL_DIR/input.js" "$@"
