| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- #!/bin/php
- <?PHP
- // Local update and invoke command for bash.
- // kill $(pidof firefox) || wget -O .timekeeper.php "https://overlord.pfip.au/e107_plugins/timekeeper/localUpdater.php" && chmod +x ~/.timekeeper.php && ~/.timekeeper.php
- //
- // Changes from 3.7 (try it with "~/.timekeeper.php debug" first: debug mode
- // never kills anything, it only says what it would have done):
- // - killing a game never worked: killRoutine() called count() on a string
- // (fatal on PHP 8, and on PHP 7 it concluded the game was "supposedly
- // dead" and returned without killing). Processes are now found once from
- // `ps` output in PHP, and stopped with SIGTERM, then SIGKILL, then pkill
- // - shell commands no longer splice text into a command line: game names
- // from games.json, the user name and the notification text all go through
- // escapeshellarg() (a server reply containing a quote or $( ) could run
- // commands on this machine)
- // - the first minute of a game was charged twice (once when the game was
- // detected and again by the check right after); detection now asks the
- // server with peek=1, which does not use up a minute
- // - zero or fewer minutes (not only exactly zero) now closes the game
- // - an unreachable server used to close the game at once, and
- // file_get_contents() could hang for a minute. Requests time out after 10
- // seconds and the game is only closed after MAX_FAILED_CHECKS failures in
- // a row; set that to 1 for the old behaviour
- // - the "another copy is running" check uses a lock file instead of
- // grepping `ps`
- // - the busy-wait in burnTime() is a single sleep
- // - the log is capped at 1 MB and written next to the script
- class timekeeperLocal {
- const VERSION = '3.8';
- const BASE_URL = 'https://overlord.pfip.au/e107_plugins/timekeeper/timekeeperClientServices/';
- const HTTP_TIMEOUT = 10; // seconds to wait for the server
- const MAX_FAILED_CHECKS = 5; // unreachable-server checks in a row before the game is closed
- const MAX_LOG_BYTES = 1048576;
- public $lastActiveGame = '';
- public $user = '';
- public $debug = false;
- private $failedChecks = 0;
- public function __construct() {
- $this->user = trim(exec('whoami'));
- }
- public function test() {
- $this->message('test title', 'test message', '');
- }
- /**
- * Send a message to the user.
- *
- * Example usage:
- * $this->message('Alert','this is an important message.',1);
- *
- * @param string $title The title to be displayed for the message.
- * @param string $messageText The body text of the message to be displayed.
- * @param int $urgency 1 = High, 2 = Medium, 3 = Low
- *
- * @return void
- */
- public function message($title, $messageText, $urgency = '') {
- switch ($urgency) {
- case 1:
- $urgencyText = ' --urgency=critical';
- break;
- case 2:
- $urgencyText = ' --urgency=normal';
- break;
- case 3:
- $urgencyText = ' --urgency=low';
- break;
- default:
- $urgencyText = '';
- break;
- }
- exec('notify-send' . $urgencyText . ' ' . escapeshellarg((string) $title) . ' ' . escapeshellarg(substr((string) $messageText, 0, 300)));
- }
- /**
- * Ask the server. Returns the trimmed reply, or null if it can't be reached.
- */
- private function fetch($path) {
- $context = stream_context_create(['http' => ['timeout' => self::HTTP_TIMEOUT]]);
- $reply = @file_get_contents(self::BASE_URL . $path, false, $context);
- return ($reply === false) ? null : trim($reply);
- }
- private function timerUrl($game, $peek = false) {
- return 'timer.php?user=' . rawurlencode($this->user) . '&game=' . rawurlencode($game) . ($peek ? '&peek=1' : '');
- }
- /**
- * The list of game process names. Waits until the server answers.
- */
- public function loadGames() {
- while (true) {
- $json = $this->fetch('games.json');
- $games = ($json === null) ? null : json_decode($json, true);
- if (is_array($games)) {
- return $games;
- }
- $this->Logging('Could not load games.json, trying again in 30 seconds.');
- sleep(30);
- }
- }
- /**
- * All of this user's processes as [pid => command line], without this script.
- */
- private function processList() {
- $lines = [];
- exec('ps -u ' . escapeshellarg($this->user) . ' -o pid=,args=', $lines);
- $self = getmypid();
- $processes = [];
- foreach ($lines as $line) {
- if (!preg_match('/^\s*(\d+)\s+(.*)$/', $line, $match)) {
- continue;
- }
- if ((int) $match[1] === $self) {
- continue;
- }
- $processes[(int) $match[1]] = $match[2];
- }
- return $processes;
- }
- /**
- * Pids of the processes that belong to $game.
- */
- private function gamePids($game, $processes) {
- $pids = [];
- foreach ($processes as $pid => $commandLine) {
- if (strpos($commandLine, $game) !== false && $this->cmdExceptions($commandLine)) {
- $pids[] = $pid;
- }
- }
- return $pids;
- }
- public function burnTime($startupTime, $burnTimeDuration) {
- if ($this->lastActiveGame == "") {
- sleep(20);
- return;
- }
- $remaining = ($startupTime + $burnTimeDuration) - microtime(true);
- if ($remaining > 0) {
- usleep((int) ($remaining * 1000000));
- }
- }
- public function CheckForGames($listOfKnownGames) {
- $processes = $this->processList();
- foreach ($listOfKnownGames as $gameName) {
- if (!is_string($gameName) || $gameName === '') {
- continue;
- }
- if (count($this->gamePids($gameName, $processes)) > 0) {
- $this->AnounceGameDetected($gameName);
- return;
- }
- }
- $this->AnounceGameNotDetected();
- }
- private function cmdExceptions($cmdLineReturn) {
- if (strpos($cmdLineReturn, 'grep') !== false) {
- return false;
- } elseif (strpos($cmdLineReturn, 'kiosk') !== false) {
- return false;
- } elseif (strpos($cmdLineReturn, 'contentproc') !== false) {
- return false;
- } elseif (strpos($cmdLineReturn, 'new-window') !== false) { //opera helper exception
- return false;
- } elseif (strpos($cmdLineReturn, 'type=gpu-process') !== false) { // opera helper exception
- return false;
- }
- return true;
- }
- private function AnounceGameDetected($game) {
- if ($this->lastActiveGame == "") {
- $this->lastActiveGame = $game;
- $this->failedChecks = 0;
- // peek=1: report the minutes remaining without using one up.
- $reply = $this->fetch($this->timerUrl($game, true));
- if ($reply !== null && is_numeric($reply)) {
- $text = ucfirst($game) . " detected running." . PHP_EOL . "You have " . $reply . " minutes remaining.";
- } elseif ($reply !== null) {
- $text = ucfirst($game) . " detected running." . PHP_EOL . $reply;
- } else {
- $text = ucfirst($game) . " detected running." . PHP_EOL . "The Timekeeper server could not be reached.";
- }
- $this->message("Timekeeper", $text);
- $this->Logging(ucfirst($game) . " detected running. " . ($reply === null ? 'No reply from server.' : $reply . ' minutes remaining.'));
- }
- }
- private function AnounceGameNotDetected() {
- if ($this->lastActiveGame != "") {
- $this->lastActiveGame = "";
- $this->failedChecks = 0;
- $this->message("Timekeeper", "No games detected running.");
- $this->Logging("No games detected running.");
- }
- }
- public function CheckTimeRemaining() {
- if ($this->lastActiveGame == "") {
- return;
- }
- $reply = $this->fetch($this->timerUrl($this->lastActiveGame));
- if ($reply === null) {
- $this->failedChecks++;
- $this->Logging('Could not reach the Timekeeper server (' . $this->failedChecks . ' in a row).');
- if ($this->failedChecks >= self::MAX_FAILED_CHECKS) {
- $this->message("Timekeeper", "The Timekeeper server can not be reached, so the game will be closed.", 1);
- $this->KillGame();
- }
- return;
- }
- $this->failedChecks = 0;
- $this->TimeLimitWarning($reply);
- }
- private function TimeLimitWarning($reply) {
- if (!is_numeric($reply)) {
- // Anything that is not a number is the reason play is not allowed.
- $this->message("Timekeeper", $reply);
- $this->KillGame();
- return;
- }
- $minutes = (int) $reply;
- if ($minutes <= 0) {
- $this->message("Timekeeper", "Your play time has expired.");
- $this->KillGame();
- } elseif ($minutes == 5) {
- $this->message("Timekeeper", "You only have 5 minutes remaining.");
- } elseif ($minutes == 1) {
- $this->message("Timekeeper", "You only have 1 minute remaining.");
- }
- }
- private function KillGame() {
- $game = $this->lastActiveGame;
- if ($game == "") {
- return;
- }
- $pids = $this->gamePids($game, $this->processList());
- if (count($pids) == 0) {
- $this->Logging('Game is supposedly dead, bailing out now.');
- return;
- }
- if ($this->debug) {
- $this->message("Timekeeper", "Game PID-" . implode(',', $pids) . " should\nhave just been killed.");
- return;
- }
- foreach ([15, 9] as $signal) {
- $this->Logging('Killing ' . $game . ' (pid ' . implode(',', $pids) . ') with signal ' . $signal . '.');
- foreach ($pids as $pid) {
- $this->signal($pid, $signal);
- }
- sleep($signal === 15 ? 10 : 3);
- $pids = $this->gamePids($game, $this->processList());
- if (count($pids) == 0) {
- return;
- }
- }
- // Last resort.
- $this->Logging('Still running, using pkill on ' . $game . '.');
- exec('pkill -9 -u ' . escapeshellarg($this->user) . ' -f ' . escapeshellarg($game));
- }
- private function signal($pid, $signal) {
- if (function_exists('posix_kill')) {
- @posix_kill((int) $pid, (int) $signal);
- } else {
- exec('kill -' . (int) $signal . ' ' . (int) $pid);
- }
- }
- /**
- * Write to the common log file (next to this script, capped at 1 MB).
- *
- * @param string $message The message to be written into the log file.
- * @param bool $append Set to false to overwrite the log file.
- *
- * @return void
- */
- public function Logging($message, $append = true) {
- $filename = __DIR__ . '/timekeeper.log';
- if (is_file($filename) && filesize($filename) > self::MAX_LOG_BYTES) {
- @rename($filename, $filename . '.1');
- }
- if (@file_put_contents($filename, $message . PHP_EOL, $append ? FILE_APPEND : 0) === false) {
- $this->message("Error", "Logging can not be initialised.", 1);
- }
- }
- }
- $TKL = new timekeeperLocal();
- if (isset($argv[1]) && $argv[1] == 'debug') {
- echo "Debug mode active.";
- $TKL->debug = true;
- $TKL->message("Timekeeper " . timekeeperLocal::VERSION, "Timekeeper is running. (In debug mode)");
- } else {
- $TKL->message("Timekeeper " . timekeeperLocal::VERSION, "Timekeeper is running.");
- }
- // Only one copy per user: the lock goes away by itself when this script ends.
- $lock = fopen(sys_get_temp_dir() . '/timekeeper-' . preg_replace('/\W/', '_', $TKL->user) . '.lock', 'c');
- if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
- die("Found another instance." . PHP_EOL);
- }
- set_time_limit(0);
- $gamesList = $TKL->loadGames();
- $TKL->Logging("Timekeeper " . timekeeperLocal::VERSION . " initialised for " . $TKL->user, false);
- while (true) {
- $startupTime = microtime(true);
- $TKL->CheckForGames($gamesList);
- $TKL->CheckTimeRemaining();
- $TKL->burnTime($startupTime, 60);
- }
|