Change desktop resolution with node.js FFI

I use my desktop workstation as a home-server of sorts. It is great to game in the comfort of your living room, on the TV, with your favorite game pad if you have an Apple TV (or any other compatible device). This is possible using a variety of software; Steam Link, Rainway or if you have an NVIDIA GPU using the open source streaming alternative Moonlight. There is a problem though: I have an ultra-wide monitor which does not play well with some game/streaming app combinations. It forces you to switch the game between fullscreen and windowed modes when you are on the actual terminal vs remote.

(There is also another problem that when you log-out from a remote desktop session, Windows locks your actual screen - which is for another day.)

This pushed me to implement a web service on my workstation that will do the resolution switch when my iPhone executes a shortcut - doing a GET request to the local computer - whenever I open one of those apps and revert when I exit them. Javascript is my natural choice of language here, as this is a one-off thing that I wanted to hack in a weekend. I knew that it was possible to do the job with the Windows API and some research pointed at node-ffi which didn’t even install because of ABI issues, being linked against a different version of node runtime (more specifically the v8 engine). From the error messages I suspect it was even linked against legacy methods that do not exist on my relatively new version. Fortunately, there is also a newer Node-API compatible fork node-ffi-napi. We will need EnumDisplaySettingsA, and ChangeDisplaySettingsA functions from User32.dll. Dynamic link libraries (or dll for short on Windows systems) are some executable code that a processes can invoke without pre-linking against their header files and node-ffi-napi is helping us with that.

Checking Microsoft’s documentation, to be able to change display settings, we see that we need a pointer to a struct in the form;

typedef struct \_devicemodeA {
  BYTE  dmDeviceName\[CCHDEVICENAME\];
  WORD  dmSpecVersion;
  WORD  dmDriverVersion;
  WORD  dmSize;
  WORD  dmDriverExtra;
  DWORD dmFields;
  union {
    struct {
      short dmOrientation;
      short dmPaperSize;
      short dmPaperLength;
      short dmPaperWidth;
      short dmScale;
      short dmCopies;
      short dmDefaultSource;
      short dmPrintQuality;
    POINTL dmPosition;
    struct {
      POINTL dmPosition;
      DWORD  dmDisplayOrientation;
      DWORD  dmDisplayFixedOutput;
  short dmColor;
  short dmDuplex;
  short dmYResolution;
  short dmTTOption;
  short dmCollate;
  BYTE  dmFormName\[CCHFORMNAME\];
  WORD  dmLogPixels;
  DWORD dmBitsPerPel;
  DWORD dmPelsWidth;
  DWORD dmPelsHeight;
  union {
    DWORD dmDisplayFlags;
    DWORD dmNup;
  DWORD dmDisplayFrequency;
  DWORD dmICMMethod;
  DWORD dmICMIntent;
  DWORD dmMediaType;
  DWORD dmDitherType;
  DWORD dmReserved1;
  DWORD dmReserved2;
  DWORD dmPanningWidth;
  DWORD dmPanningHeight;

Which we can build with some ref extensions;

const ref = require('ref-napi');
const Struct = require('ref-struct-di')(ref);
const ArrayType = require('ref-array-di')(ref);
const ffi = require('ffi-napi');

const uchar = ref.types.uchar;
const short = ref.types.short;
const ushort = ref.types.ushort;
const ulong = ref.types.ulong;
const bool = ref.types.bool;
const string = ref.types.CString;

const DEVMODE = Struct({
  dmDeviceName: ArrayType(uchar, 32),
  dmSpecVersion: ushort,
  dmDriverVersion: ushort,
  dmSize: ushort,
  dmDriverExtra: ushort,
  dmFields: ulong,
  dmSkip8: ArrayType(short, 8),
  dmColor: short,
  dmDuplex: short,
  dmYResolution: short,
  dmTTOption: short,
  dmCollate: short,
  dmFormName: ArrayType(uchar, 32),
  dmLogPixels: ushort,
  dmBitsPerPel: ulong,
  dmPelsWidth: ulong,
  dmPelsHeight: ulong,
  dmSkip10:  ArrayType(ulong, 10),
const PDEVMODE = ref.refType(DEVMODE); // A pointer to the above struct

Here, you can see that we have just skipped over most of the struct. We are actually only interested in dmBitsPerPel, dmPelsWidth and dmPelsHeight. But I have included some of the other fields as well for demonstration purposes. Note that the strings dmDeviceName and dmFormName are not regular C string pointers but instead they are in-place allocated 32 bytes of memory. Then we inform the library on the shape of the functions exposed by our dynamic link library using the above types;

// This will throw if the dll is not available
const User32 = ffi.Library('User32', {
  // BOOL EnumDisplaySettingsA(
  //   LPCSTR   lpszDeviceName,
  //   DWORD    iModeNum,
  //   DEVMODEA \*lpDevMode
  // );
  EnumDisplaySettingsA: \[ bool, \[ string, ulong, PDEVMODE \] \],
  // LONG ChangeDisplaySettingsA(
  //   DEVMODEA \*lpDevMode,
  //   DWORD    dwFlags
  // );
  ChangeDisplaySettingsA: \[ long, \[ PDEVMODE, ulong \] \],

Now we can do the actual foreign function call;

const ENUM\_CURRENT\_SETTINGS = 4294967295; // Flag to get current settings
const defaultMode = new DEVMODE(); // Allocate the struct
const enumSuccess = User32.EnumDisplaySettingsA(null, ENUM\_CURRENT\_SETTINGS, defaultMode.ref());
if (enumSuccess) {
  defaultMode.dmBitsPerPel = 32;
  defaultMode.dmPelsWidth = width;
  defaultMode.dmPelsHeight = height;
  const result = User32.ChangeDisplaySettingsA(defaultMode.ref(), 0);

This will magically change our primary display device’s resolution to width x height if the result is 0. There are a few things to go over here. First of all the Struct package creates a constructor that we can instantiate, which will allocate the actual memory buffer without doing an explicit ref.alloc like it is in the examples at the original tutorial. We pass EnumDisplaySettingsA, a pointer to this structure -which is now in memory- using the .ref() function. Then it will nicely fill it with the actual display settings as an out parameter. Another nice feature of the Struct is that it is mutable via js, thanks to its inner getters/setters to put the correct bytes at correct offsets in the underlying Buffer. So we simply update the relevant properties of the object and call ChangeDisplaySettingsA with a ref to it. Finally, passing js null is equivalent to ref.NULL and will get accepted as a null pointer to a C style string as the first parameter to specify the “current” device.

Keen eyes may have already seen that ENUM_CURRENT_SETTINGS is actually the maximum representable 32 bit integer. In Microsoft’s documentation, its value is not documented but if you actually go to the SDK’s header file, you will see that it is set as -1, which is indeed the max int when we get its two’s complement. It makes an easy choice of value for a flag as it is a power of two and will be easily ored with other values as needed. This value simply means the “active value” rather than “registry value”.

In the end this approach didn’t work for my use case because of Session 0 Isolation. I’m currently registering my node script as a Windows service using nssm.exe, which executes node in a service context thus making it impossible to interact with the display settings using these APIs. I needed an additional UI application that will listen on a socket and update the resolution, which I have eventually had to build but it is for another day as well.

In the end I have learned a new set of tools that nicely abstracts underlying NAPI buffers in js, with its code open in the wild. In my case I’m using Windows libraries but it is also completely compatible with Linux or OSX dynamic linkables as well, so it is a really powerful tool. It is not without its downsides though. For example, it is not possible for node-ffi to know the actual call signature of the library and the responsibility is at the hands of the developer to construct the correct payload. It is totally possible to provide incorrect data sizes and wreak-havoc with garbage parameters to the library. Under the hood it uses libffi and I couldn’t see any boundary checking for the return value (or the parameters) of the foreign function. This means that it is possible to do out of bounds memory accesses if you are not careful with the exact size and introduce security vulnerabilities for the system. Similarly providing a smaller than required buffer for the out parameters, it is possible that the remote function writes to an incorrect place or we can easily mess with the provided parameters. On the flip side, it does type-check the parameters provided to the js function beautifully once you set them when calling ffi.Library. Let me know in the comments below if you have anything to add or ask!

© Ali Naci Erdem 2024