Radix OS is a project that I developed as a fun UX exercise while vacationing between jobs. It is a simulated in-browser operating system, made using Radix, Zustand, DnD kit & React.
Try out the live demo
The source is available as always on github.
Some features include...
- Window management
- Minimize, maximize, rearrange, tile
- Customization
- Background, dark/light mode
- File system
- Create files and folders, drag and drop moving
- Persisted in localStorage
- Apps
- Image preview
- Web browser (barely functional)
- Code editor
- Terminal
Drag & Drop
I have manually implemented drag & drop enough times to know that I was better off using a library here, so I did a bit of research and landed on dnd kit.
dnd kit
ended up being a great solution. The package allows me to think in simpler terms (X
is draggable, Y
is droppable). Adding restrictions to the draggable elements was straight forward enough, by utilizing modifiers.
I would love to look more into dragging between different apps, as the overflow of the current application window would make this difficult to implement currently.
It is also possible to rearrange the icons on the desktop by dragging and dropping, but this grid-like behaviour has a few issues that I have yet to resolve. 💩
Persisting data
I decided to only use local storage for persisting data between reloads, instead of hosting a proper backend and requiring login for public use. To do this easily with Zustand, I used persist with createJSONStorage(() => localStorage)
.
Believe it or not, this is my first time using Zustand. Seems like a good option for client-side state, in the few scenarios I normally need that.
The project is built with lots of isolated layers that utilize eachother. These layers each have their own store:
- File system
- Window management
- Settings
The file system
A file
looks like this:
type File = {
name: string;
launcher: Launcher[];
data: string;
};
Launching files
The data
property on files is a string value that can be read and understood by different launchers.
As an example, the web browser can take either a link, or HTML. The image previewer can take an SVG, or a data:image string.
Example file
{
"name": "index.html",
"launcher": ["web", "code"],
"data": "<h1>Hello world!</h1>"
}
A Launcher
is just a string union of different types of launchers. Which launchers are available determines what apps are available to launch the file from. You can configure this per file from the terminal app:
Terminal (radix-os)
fs [path] --ex [launcher]
# example:
fs "Documents/My cool file" --ex browser
Referencing files & folders
In the Zustand store, I designed most of the actions to take in a string path to reference files or folders. I utilize a couple of helper functions that I wrote to work with these paths in the store, and in the terminal app:
findNodeByPath(path: string, tree: FsNode)
- Takes a tree-structure and a string path, and returns the node if found
parseRelativePath(cd: string, path: string)
- Takes in two string paths, an absolute current path - and a relative path.
-
const path = parseRelativePath( "Home/Documents", "../Images" ); // "Home/Images"
Window management
The window management state is made up of the following:
type Window = {...};
type WindowStore = {
windows: Window[];
activeWindow: Window | null;
windowOrder: symbol[];
// ...actions
}
windowOrder
determines the Z index of the windows, and the activeWindow
is the currently focused window (as indicated in the multi tasking bar).
The windows themselves are shell components that are draggable within the desktop bounds. If shift is held down while dragging the windows, "drop-zones" appear. Dropping the window in one of these zones will tile it accordingly.
Creating an NPM package
After working on the project for a while, I realised it could be useful to be able to easily swap out the file system, and to customize which applications are available. Then it could be published as an NPM package in the form of a React component that can be configured with props.
To do this, I had to rewrite the file system to be asynchronous - and expose the interface so methods can be implemented however wanted. The existing zustand integration is exported from the package as well so it can be easily set up with a client-side store.
I also had to do a bit of refactoring to make passing applications work properly. Apps can now be created using a createApp
method:
import { createApp, useWindowStore, useFs } from "radix-os";
import { useAppLauncher } from "../lib/radixos";
import { Box } from "@radix-ui/themes";
const MyCustomApp = createApp((props) => {
const hasFile = props.file !== undefined;
if (hasFile) {
const { file, path } = props.file;
// ... was opened by a file
}
const windowState = props.appWindow;
const windowStore = useWindowStore();
const fileSystem = useFs();
const { launch, open } = useAppLauncher();
return <Box>...</Box>;
});
Apps can then be passed when setting up RadixOS:
import {
RadixOS,
setupApps,
fsZustandIntegration,
} from "radix-os";
const applications = setupApps({
appId: "customapp",
appName: "Custom App",
component: MyCustomApp,
defaultWindowSettings: {
title: "Custom App",
},
});
function App() {
return (
<RadixOS
fs={fsZustandIntegration}
applications={applications}
desktopShortcuts={[]}
/>
);
}