My journey in building display-dj, a cross-platform application to control monitors / display brightness
In this article, I will describe my journey and challenges I faced when building display-dj , a cross-platform desktop application to control integrated laptop display brightness along with external display brightness.
The Problems:
The motivations:
What I showed you here is nuisances I have to deal with on a daily basis. I worked with both Mac OSX and Windows. There is no easy solution out there that allows me to go from a dark room mode (brightness all the way down and dark mode on) to a well lit room mode (brightness cranked all the way up and dark mode off).
Normally when this happens, I have to manually tune each monitors one by one with their physical controls and also update the display individually. This change should be easier with a few keystrokes or mouse clicks, and not you fiddling with physical controls and clicking on components that are buried deep underneath nested System Preference menus.
No joke, the following pictures are real photos from my workstation. Day and night difference in comparison. In day mode, dark mode is turned off, and brightness is turned to the max. Whereas night mode, dark mode is turned on, and brightness is dimmed down to the minimum.
The challenge of work from home in the last 2 years with 2 young toddlers is that they can charge into your room any time of the day and playing with the light switch. This is my defense mechanism for those sudden changes in light intensity. I can toggle between 2 different modes rather quickly with a key stroke: going to the dark side vs going to the light side of the force.
The Links:
The Stack:
First, I started building out the boilerplate app with Electron , React and Typescript . For simplicity, I used pure Webpack instead of create-react-app. I used raw SCSS and styles with no UI system design to simplify things. The application is simple and doesn't have a router.
The Journey:
Initially, in the first version of the UI, my layout lists out all the individual monitors with the option to change all brightness at the bottom.
This first version is too complicated to visualize and takes up too much space. As from the code perspective, you will have a harder time synching the sliders anytime all monitors slider changes its value.
So I decided to make a second version of the UI to hide the complexity of the sliders in 2 modes. Mode 1 (collapsed mode) allows you to change brightness of all monitors and mode 2 (expanded mode) allows you to update individual display brightness. You clicked on the top right expansion icon to go between mode. At the end, version 2 is what I settled with the final app.
For this program, I made the decision to have the app as part of the system tray or taskbar. So it's easier to get to from the users' perspective. And it also supports key bindings, so the user can trigger a brightness change based on key bindings.
Then I spent majority of my time debugging and investigating how to control the brightness of the internal display as well as those external displays using pure software instead of physical controls. This is when things get really complicated really quickly.
Due to this complexity, initially I built out the app with support only for Windows and slowly investigating to see if support for Mac OSX is even an option. At the end, it is possible to support both platforms.
Finally, my time is allocated to setting up the CI /CD pipeline for building and releasing the electron app. This time around, I chose electron-packager over electron-builder which I used in my previous project for electron. And I like electron-packager more than electron-builder because you can actually control and fine tune the configuration using Java Script, whereas electron-builder is a stricter solution and requires more configuration to get right. You can refer to this dist.js file on how I set it up.
Challenges with Internal Display Interactions
For integrated displays, Mac OSX and Windows handle brightness controls very differently.
For Windows, internal display brightness can be controlled using the WMI (Windows Management Instrumentation) . Below are the snippets of the script I used to get and update internal display brightness.
Getting brightness of internal display on Windows
(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightness).CurrentBrightness
Updating brightness of internal display on Windows
(Get-WmiObject -Namespace root/WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,<newBrightness>)
For Mac OSX, internal display can be updated in 2 ways.
Using AppleScript to send a keyboard event for brightness down and brightness up. But this option does not give you a more refined control such as change the brightness to 80% instead of just going up or down by a notch.
# turn brightness one notch down
osascript -e 'tell application "System Events"' -e 'key code 144' -e ' end tell'
# turn brightness one notch up
osascript -e 'tell application "System Events"' -e 'key code 145' -e ' end tell'
I ended up settled with the second option. In this case, using a CLI tool called brightness (can be installed via brew install brightness)
Getting brightness of internal display on Mac OSX
领英推荐
brightness -l
Updating brightness of internal display on Mac OSX
brightness -d <newBrightness>
Challenges with External Display Interactions
Things don't look any better with external displays. For external displays, we have a protocol called DDC/CI (Display Data Channel / Control Interface) . This protocol is supposed to make controlling external monitors with software easier, but in reality vendors choose to do their own things and implemented part of the protocol. We ended up with a lot of discrepancy. This makes it nearly impossible to support a wide array of displays and setups.
For Windows, I ended up using an npm package called @hensm/node-ddcci . It works pretty well with the external monitors I have at home. The API is also pretty simple to use and everything seems to be keyed off a monitorId.
const ddcci = require("@hensm/ddcci");
for (const monitor of ddcci.getMonitorList()) {
// to get the monitor brightness
? ? console.log(`${monitor} current brightness: ${ddcci.getBrightness(monitor)}`);
?// to set the brightness
? ddcci.setBrightness(monitor, 25);
}
For Mac OSX, I ended up using a CLI tool called ddcctl . Given the same setup I have at home, mac support for DDC/CI is pretty inconsistent. ddcctl seems to fail intermittently trying to get the current brightness level of the same external display. So I ended up caching the last known brightness of each display and use that instead as a fallback when ddcctl failed.
The API of ddcctl is pretty straightforward. Below is the code snippet. The only thing is we need to figure out which displayId to use for the API.
# getting the brightness for a display
ddcctl -d 1 -b \?
# setting the brightness for a display
ddcctl -d 1 -b 50
Challenges with bundling dependent binaries
This application requires a few extra dependencies in the form of binaries such as ddcctl and brightness for mac and volume helper for windows to run. Initially I would ask the users of the application to download and install these dependencies manually.
I find it extremely hard to ask non-tech-savvy users to do such a thing, then I went on a journey to figure out how I can bundle that as part of the application and users can start using display-dj out of the box without any additional steps.
What I did was I create a task that bundle these binaries into the final build and reference the path of these binaries using process['resourcePath']. The following is a sample code I used to get the path to the resource binary.
const _getVolumeHelperBinary = async () => path.join(process['resourcesPath'], `win32_volume_helper.exe`);
Then as part of the build, these binaries are automatically copied over to the final bundle.
Challenges with tray menu positioning
Trying to position the tray icon and its menu on the desktop is really complicated because there are so many variations of the tray orientations and sizes.
On Mac OSX, things are simpler since the tray can only be located on the top of the display.
On Windows, things are wild. You can change the taskbar location to left side, right side, top side, bottom side and they can have different taskbar dimensions. Also the tray icon of your app might be collapsed inside the overflow menu.
The way I tackled this is using screen API from Electron to get the width and height of the screen and then define where to place the tray menu. In my case, I split it up to 4 quadrant and depending on the tray icon location, it can be in either one of those quadrant. This calculation is using the above API for screen width and height.
import { screen } from 'electron';
const mainScreen = screen.getPrimaryDisplay();
const mainScreenSize = mainScreen.size;
CI / CD challenges
A challenge I faced in setting up the CI/CD pipeline is that some packages are only OS dependent and can't be compiled for other platform. In my case, @hensm/ddcci can't be compiled on a Mac.
The solution I came up with for this issue is to use optionalDependencies in package.json to store any OS specific addons and create my own script to capture the packages that need to be installed for the app in different operating systems.
Here's a snippet of the package.json
"optionalDependencies": {
? "@hensm/ddcci": "^0.1.0",
? "dark-mode": "^4.0.0",
"electron-installer-dmg": "^3.0.0",
? "electron-winstaller": "^5.0.0"
}
Then I relied on --no-optional flag of npm to install only required dependencies for the core app.
npm install --no-optional
Then I created a pre-build hook to install the OS specific dependencies.
Sample pre-build.js script. Note that here I also define which DisplayAdapter to use for the build. In this case, specific adapter for Darwin (Mac OSX) and Win32 (Windows) will be selected for the bundling.
const fs = require('fs')
const { exec } = require('child_process');
let source;
const dest = `src/electron/utils/DisplayAdapter.ts`;
let packages = [];
switch (process.platform) {
? case 'win32':
? ? source = `src/electron/utils/DisplayAdapter.Win32.ts`;
? ? packages = ['@hensm/ddcci', 'electron-winstaller'];
? ? break;
? case 'darwin':
? ? source = `src/electron/utils/DisplayAdapter.Darwin.ts`;
? ? packages = ['dark-mode', 'electron-installer-dmg'];
? ? break;
}
// install extra dependencies
exec(`npm install ${packages.join(' ')}`);
fs.copyFileSync(source, dest);
You can refer to the whole CI/CD definition here
The Future Features:
Although this is a simple application, but there are so many quality of life improvements you can do. Below is a list of potential features I think will make it better:
The Summary: