Making a Speedrun Timer: Chapter 3

kevthedev

Kev the Dev

Posted on April 9, 2023

Making a Speedrun Timer: Chapter 3

Abandon Ship 🚢

Well after a few successful blog posts, we've finally hit our first big problem, and it's a show-stopper. What do I mean by that? Well it has to do with initial design considerations.

When I first designed our app, I went down the PWA route, thinking that the browser had everything we needed for our app to function properly.

How do we tell him?

But I was wrong. I overlooked one of the most crucial features to our speedrun timer, global shortcuts.

Global shortcuts are keys or key combinations that you can type on your keyboard to trigger some sort of functionality on your app or operating system (e.g. the windows key opening the app launcher).

If you're familiar with LiveSplit and you've been a speed-runner for a decent amount of time, you'll know that global shortcut keys are a lifesaver when it comes to tracking your splits. It's not practical to make sure that your timer remains in the foreground while you're speed-running a game. Too often can you accidentally leave another application open and completely mess up your split times or your entire run!

So do you know what CAN'T perform global shortcuts? The browser.

Migrating from PWA to Electron

Oh boy, what do we do about this? Well fortunately, we're not out of options. In fact, I sort of accounted for this in the beginning.

One of the reasons I chose to write the frontend for this project in Vue/JavaScript, is that if I needed to, I could easily migrate to an Electron application. Electron.js gives us a way to build cross-platform desktop applications in JavaScript. So through this migration, we can utilize global shortcuts and run our app on all major desktop platforms!

Code Changes

I won't go over ever single code change, as that would make for an insanely long post. Furthermore, our code is going to largely remain the same. What will change is our project structure and the added global shortcut functionality.

Our project structure will be divided into 3 parts:

  • The node.js code that communicates between our app and the operating system.
  • The renderer code, which is essentially just our frontend.
  • The preloader code, which serves as a bridge between node.js and the frontend.

Node.js Code

Our node.js code is mostly given to us by default when scaffolding a Electron Forge project. A lot of the comments are generated from it as well. The only code we'll change is we want to register the global shortcuts once the application is ready. We handle this with node.js because we need to listen to keyboard input on an operating system level, not just on the application itself. Once the callback function is triggered, we then send the commands to the mainWindow to interact with our timer.

It's important to note that once binding a keyboard shortcut (like the number "1" shown here), you won't be able to type with or use that key on any other application 😬. This limitation was a design choice by the Electron team to prevent the development of a keylogger. There may be some sort of work-around but I won't get into that here. Feel free to comment if you know of another solution!

// main.js
const { app, BrowserWindow, globalShortcut } = require('electron');
const path = require('path');

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
  app.quit();
}

let mainWindow;
const createWindow = () => {
  // Create the browser window.
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
    },
  });

  // and load the index.html of the app.
  if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
    mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
  } else {
    mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
  }

  // Open the DevTools.
  mainWindow.webContents.openDevTools();
};

// This method will be used to setup our global shortcuts code.
// https://electronjs.org/docs/latest/api/global-shortcut
app.whenReady().then(() => {
  globalShortcut.register('1', () => {
    mainWindow.webContents.send("global-timer", 0);
  })
  globalShortcut.register('2', () => {
    mainWindow.webContents.send("global-timer", 1);
  })
  globalShortcut.register('3', () => {
    mainWindow.webContents.send("global-timer", 2);
  })
}).then(createWindow);

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  // On OS X it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.
Enter fullscreen mode Exit fullscreen mode

Preloader

As mentioned before, this code will serve as a bridge between the node.js code (operating system communication) and the renderer code (frontend). Currently, all we need to do is to expose the ipcRenderer API to allow the renderer to communicate with node.

// preloader.js
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
    listenForTimerCommands: (listener) => ipcRenderer.on('global-timer', listener)
});
Enter fullscreen mode Exit fullscreen mode

Renderer

Finally, we have our renderer. This code is simply our entry file (the old main.js) from our PWA before.

// renderer.js
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";

createApp(App).mount("#app");
Enter fullscreen mode Exit fullscreen mode

Let's go ahead and add the code on the frontend to react to a global shortcut input.

// Stopwatch.vue
<script setup>
import { onMounted, onUnmounted } from 'vue';
import { useStopwatch } from '../composables/stopwatch';
import { WorkerCommands } from '../helpers/timer-worker-helper';

const {
  timerTxt,
  onTimerStart,
  onTimerStop,
  onTimerReset,
  onTimerInit,
  onTimerTeardown
} = useStopwatch();

onMounted(() => {
  onTimerInit();
  window.electronAPI.listenForTimerCommands((_, data) => {
    switch(data) {
      case WorkerCommands.START:
        onTimerStart();
        break;
      case WorkerCommands.STOP:
        onTimerStop();
        break;
      case WorkerCommands.RESET:
        onTimerReset();
        break;
      }
  });
});

onUnmounted(() => {
  onTimerTeardown();
});

</script>

<template>
  <div>
    <p>{{ timerTxt }}</p>
    <button @mousedown="onTimerStart">Start</button>
    <button @mousedown="onTimerStop">Stop</button>
    <button @mousedown="onTimerReset">Reset</button>
  </div>
</template>

<style scoped>
button {
  margin: 0 5px;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Conclusion

There you have it! We have now successfully migrated from PWA to Electron. I'm actually very excited to see what desktop development will have in store for us. I've already learned a lot!

If there's any other code changes you want to see or want me to go over, feel free to comment on the post with your questions. You can also follow along in the GitHub repo.

💖 💪 🙅 🚩
kevthedev
Kev the Dev

Posted on April 9, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related