1. Tutorials
  2. How to Build a Multi-Window TodoMVC App with ToDesktop Builder

Tutorials

How to Build a Multi-Window TodoMVC App with ToDesktop Builder

Learn how to create a multi-window ToDesktop Builder application

Introduction

TodoMVC is a project that showcases the same todo application built across a range of JavaScript frameworks.

Current State of the TodoMVC application

ToDesktop Builder is an application for creating cross-platform desktop apps with a few lines of code. In this guide, you will learn how to build and release a multi-window TodoMVC app with the ability to:

  • Create, update, and delete todos.
  • Organize todos into different workspaces.
  • Capture todos in a floating window that synchronizes with the main window.
End state of the TodoMVC application

You will take full advantage of the no-code interface and the plugin ecosystem that ToDesktop offers. By the end, you will have a fully packaged application that can be installed on any operating system.

INFO

📢 Prerequisites

To complete this guide, you will need the following:

  • Node.js installed on your local machine.
  • Git installed on your local machine.
  • Github account for hosting your static app.
  • ToDesktop account for releasing your desktop app.

Step 1: Set up ToDesktop Builder project

Install ToDesktop Builder

First, you'll need to download the ToDesktop Builder application. Once done, install and run the downloaded application.You should now see the following screen:

ToDesktop Builder's Welcome Screen

Click the Get Started button to begin the authentication process. This will link the web app's credentials with that of the desktop app. Once authenticated, you will be navigated to ToDesktop Builder.

Create new project

If this is your first time using ToDesktop Builder, you will see the create new project screen. If you don’t see this screen, click the application dropdown and select create new project.

ToDesktop Builder's create new project screen

Once here, click the Blank project and enter the following:

App Title TodoMVC IPC
App Icon Click to Download

Click the Create App button to complete the initial setup.

Create a hidden window

Next, we’ll create a hidden window that will run in the background. This window is the entry point that will orchestrate the other windows and views.

Using ToDesktop Builder, navigate to Windows and click the plus icon in the top-right corner. Make the following updates in the Overview panel:

Window Name Index
Window URL http://localhost:8080
Window Visibility Hidden
Set as main window Checked (✅)

You can now click the Run button to start the app. You should now see the TodoMVC icon in your dock, but no visible window.

TodoMVC logo shown in a MacOS Dock

Step 2: Setup starter project

We have provided a starter project to help get you set up. This contains the index, main, aside and spotlight folders that you’ll need to create this app.

Clone the Github repo

Navigate to the project Repo to find the template files. Click the Fork button to duplicate the project to your account. Once forked, click the Code dropdown button and copy the web URL.

Now using your terminal, navigate to a directory of your choice and paste the web URL as follows:

        git clone <paste-web-url-here>

      

Navigate into the directory and install the TodoMVC dependencies.

        cd todesktop-mvc-ipc
npm install

      

Install custom plugins

ToDesktop provides a modular plugin system that can add functionality to our desktop app.

We’ll use the @todesktop/ipc plugin to add functionality for communicating between windows and views.

INFO

When should you use @todesktop/ipc over the BroadcastChannel API?

You should use @todesktop/ipc if you need to communicate between different Electron sessions or domains with a different origin. For all other purposes, we recommend using the BroadcastChannel API.

Using ToDesktop Builder, navigate to Plugins via the sidebar. Then click the Explore button next to IPC Communication and install the plugin.

Now using your terminal in the todesktop-mvc-ipc directory, install the related client package:

        npm install @todesktop/client-ipc

      

While we’re at it, we’ll also install @todesktop/client-core. This is a client package that exposes functionality for managing windows and views in a desktop environment.

        npm install @todesktop/client-core

      

Start the app

Once the installation has been completed, you can start the application with:

        npm run start

      

This starts our app on http://localhost:8080. The project will now reload when any of the javascript files in the following folders change:

View Responsibility
index Creating multiple windows and views
aside Creating, selecting, and restoring workspaces.
main Communicating workspace status and creating todos for a selected workspace.
spotlight Quickly capturing todos for a selected workspace

Step 3: Creating multiple windows and views via index

Now that we have the necessary plugins, we can start writing code. In this section, we’ll use the APIs provided by ToDesktop to manage our windows, views, and cross-communication.

Open the project in a code editor of your choice and add the following lines of code to index/index.js:

        import { nativeWindow, views, webContents } from '@todesktop/client-core';
import { publish, subscribe } from '@todesktop/client-ipc';

      

Directly after, we’ll define the global variables that we’ll need throughout this file:

        const workspaces = [];
let selectedWorkspace = null;
let asideMainWin;
let spotlightWin;

const ROOT = process.env.NODE_ENV === 'production' ? '.' : 'http://localhost:8080';

      
  • workspaces will store references to the workspaces that we create.
  • selectedWorkspace will store the currently-selected TodoMVC workspace.
  • asideMainWin will store a reference to the window that shows a list of our workspaces. This window will contain the aside and main view.
  • spotlightWin will store a reference to the window that allows us to quickly capture todos. This window will contain the spotlight view.
  • ROOT will store the base URL for our application.

Composing windows with views

Let’s create the body of the background function. This function is responsible for creating the initial windows and views:

        const background = async () => {
  asideMainWin = await nativeWindow.create({
    width: 750,
    height: 480,
    frame: false,
    vibrancy: 'under-window',
    transparent: true,
    titleBarStyle: 'hiddenInset',
    trafficLightPosition: { x: 12, y: 15 }
  });

  spotlightWin = await nativeWindow.create({
    width: 400,
    height: 160
  });

  await createWindowView('addBrowserView', asideMainWin, {
    x: 0,
    y: 0,
    w: 0.3,
    h: 1,
    url: `${ROOT}/aside.html`
  });

  window.addEventListener('unload', async () => {
    await Promise.all([
      nativeWindow.destroy({ ref: asideMainWin }),
      nativeWindow.destroy({ ref: spotlightWin })
    ]);
  });
};

      

Starting from the top of the function, we’re creating new windows and assigning them to our asideMainWin and spotlightWin variables. We then add a browser view to asideMainWin. This view loads a URL that points to our aside html file.

To help visualize, executing our background function would create the following in our desktop environment:

Diagram of two desktop ap windows on a flat background

Finally, we add a listener to the window unload event. This will destroy the windows (so that they can be recreated again) whenever we make a code change in our development environment.

Creating the helper functions

With the overall structure understood, we can start defining the helper functions that are executed in the background function.

Create window view

The createWindowView function accepts three arguments:

  • The composition action (action) to conduct on the view, with options being addBrowserView or setBrowserView.
  • The target window (win) to be acted on.
  • Arguments (args) that dictate the view’s position and preferences.

It uses these arguments to create a new view, load a url into that view, and then position that view within the window.

        const createWindowView = async (action, win, args) => {
  const { x, y, w, h, url, partition } = args;

  const view = await views.create({ webPreferences: { partition } });
  await loadViewURL(win, view, { url, action });
  await setViewBounds(win, view, { x, y, w, h });

  return view;
};

      

Load view URL

loadViewURL attaches the browser view to a window, accesses the web contents of that view, and then loads the target url into the web contents:

        const loadViewURL = async (win, view, { url, action }) => {
  if (action === 'addBrowserView') {
    await nativeWindow.addBrowserView({ ref: win, viewRef: view });
  } else {
    await nativeWindow.setBrowserView({ ref: win, viewRef: view });
  }

  const viewContents = await views.getWebContents({ ref: view });
  await webContents.loadURL({ ref: viewContents }, url);
};

      

Set view bounds

setViewBounds gets the size of the browser window, and then uses that to determine the bounds of the view. Once that’s done, we instruct the view to resize itself when the parent dimensions change.

        const setViewBounds = async (win, view, { x, y, w, h }) => {
  const [currWidth, currHeight] = await nativeWindow.getSize({ ref: win });

  await views.setBounds({
    ref: view,
    bounds: {
      x: Math.round(currWidth * x),
      y: Math.round(currHeight * y),
      width: Math.round(currWidth * w),
      height: Math.round(currHeight * h)
    }
  });

  await views.setAutoResize({
    ref: view,
    dimensions: {
      width: true,
      height: true,
      horizontal: true,
      vertical: true
    }
  });
};

      

At this point, we can invoke our background function by adding this line after all of our function definitions:

        background();

      

You should now see the following two windows when you run ToDesktop Builder:

Current state of the asideMainWin and the spotlightWin

Responding to window events

With the core functionality of our background function in place, we now need to respond to events that might come from these newly-created windows and views.

We’ll be using the subscribe and publish functions that we imported from @todesktop/plugin-ipc to react to the following events:

  • workspace:created: This occurs when a workspace has been created. We react by publishing a workspace:synced event after we add the workspace to our internal list.
        subscribe('workspace:created', async ({ title }) => {
  if (!workspaces.find((view) => view.title === title)) {
    workspaces.push({ title });
    publish('workspace:synced', { title });
  }
});

      
  • workspace:selected: This occurs when a workspace has been selected. We react by attaching the newly-selected workspace views to our existing windows. The logic inside of the if condition replaces our existing views with views corresponding to the new workspace title. This is what gives us the ability to separate workspaces for our TodoMVC views. Each workspace has its own partition which allows it to operate independently of other workspaces.
        subscribe('workspace:selected', async ({ title }) => {
  if (workspaces.some((v) => v.title === title)) {
    await createWindowView('setBrowserView', spotlightWin, {
      x: 0,
      y: 0,
      w: 1,
      h: 1,
      url: `${ROOT}/spotlight.html`,
      partition: `memory:${title}`
    });

    await nativeWindow.setTitle({ ref: spotlightWin, title });
    if (selectedWorkspace) {
      await nativeWindow.removeBrowserView({
        ref: asideMainWin,
        viewRef: selectedWorkspace
      });
    }

    selectedWorkspace = await createWindowView('addBrowserView', asideMainWin, {
      x: 0.3,
      y: 0,
      w: 0.7,
      h: 1,
      url: `${ROOT}/main.html`,
      partition: `persist:${title}`
    });
  }
});

      

After we react to the workspace:selected event, we’ve effectively created the main and spotlight views that occupy our global windows.

Diagram showing the urls loaded by the main, aside, and spotlight windows.

Step 4: Creating, selecting, and restoring workspaces via aside

Now that the hidden index window can construct our desktop environment, we can start adding functionality around creating workspaces and todos. Like before, we’ll set up our imports and global variables first in aside.js:

        import './aside.css';
import { subscribe, publish } from '@todesktop/client-ipc';

let selectedWorkspaceTitle = null;
let workspaces = {};

      

Rendering workspaces in response to events

As we’re not using a Javascript framework, we’ll need a render function to update our user interface when our data changes.

This function will append our list of workspaces to a HTML element on the web page. Each workspace will then have an event listener so that it knows when it has been selected. The function, and a commented sequence of steps, is detailed below:

        function render() {
  // 1. get root element of render tree and clear its contents
  const root = document.getElementById('workspacesList');
  root.innerHTML = '';

  // 2. iterate over each workspace, destructuring their needed properties
  Object.values(workspaces).forEach(({ title, active }) => {
    // 3. create the workspace item, giving the selected one a class of active
    const item = document.createElement('li');
    item.classList.add('item');
    if (title === selectedWorkspaceTitle) {
      item.classList.add('active');
    }

    // 4. create a label element with the title of the workspace
    const label = document.createElement('p');
    label.textContent = title;
    item.appendChild(label);

    // 5. create a status element  for keeping track of active todos
    const status = document.createElement('p');
    status.textContent = active || 0;
    item.appendChild(status);

    // 6. attach item to the root node and add a handler to update the selected workspace
    root.appendChild(item);
    item.addEventListener('click', () => {
      selectedWorkspaceTitle = title;
      publish('workspace:selected', { title });
    });
  });
}

      

In order to create new workspaces, we’ll listen to the submit event from our form element. When this event triggers, we’ll access the value from the input field and use that as the title of our workspace.

        document.querySelector('form').addEventListener('submit', (e) => {
  e.preventDefault();

  const input = document.querySelector('input');
  if (input.value) {
    createWorkspace({ title: input.value });
    input.value = '';
  }
});

      

The createWorkspace function emits that a workspace has been created, updates its internal list of workspaces, and causes a re-render:

        const createWorkspace = (workspace) => {
  publish('workspace:created', workspace);
  workspaces[workspace.title] = workspace;
  render();
};

      

Subscribing to workspace events

While we can now handle workspace creation through the aside view, we don’t know when a workspace’s todos have been created. If we refer back to the table of responsibilities, we’ll observe that only the main view knows about this:

View Responsibility
aside Creating, selecting, and restoring workspaces.
main Communicating workspace status and creating todos for a selected workspace.

To resolve this, the aside view needs to listen to workspace:status events that come from the main view. When this event is triggered, the aside view will update the associated workspace, and then cause a re-render.

        const statusUnsubscribe = subscribe('workspace:status', async (status) => {
  const activeWorkspace = workspaces[selectedWorkspaceTitle];
  if (activeWorkspace) {
    workspaces[selectedWorkspaceTitle] = { ...activeWorkspace, ...status };
  }

  render();
});

window.addEventListener('unload', () => statusUnsubscribe());

      

Saving, loading, and hydrating workspaces

As the last piece of functionality for aside, we’d like to be able to load and save our state if the user exits the application. This can be done through an invocation that saves a snapshot of our state to local storage every 5 seconds.

        setInterval(() => localStorage.setItem('workspaces', JSON.stringify(workspaces)), 5000);

      

Loading saved workspaces is then as simple as parsing what is contained in local storage.

        const savedWorkspaces = localStorage.getItem('workspaces');
if (savedWorkspaces) workspaces = JSON.parse(savedWorkspaces);

      

These saved workspaces represent the workspaces that we need to create in the user interface. This can be done by calling the createWorkspace function that we previously defined. Alternatively, if we find that there are no saved workspaces, we’ll create a default workspace called General.

        const workspacesToHydrate = Object.values(workspaces);

if (workspacesToHydrate.length) {
  for (const workspace of workspacesToHydrate) {
    createWorkspace(workspace);
  }
} else {
  createWorkspace({ title: 'General' });
}

      

At this point, we can let our hidden index window know that we’ve selected the first workspace we could find.

        const selectedWorkspace = Object.values(workspaces).find(Boolean);

selectedWorkspaceTitle = selectedWorkspace.title;
publish('workspace:selected', selectedWorkspace);

      

While not very presentable, you should be able to play around with creating and selecting workspaces

Current state of the aside view

Current state of the aside view

To add some polish, put these styles in the aside/aside.css file:

        body {
  margin: 0;
  padding: 0;
}

.container {
  width: 100vw;
  height: 100vh;
  display: flex;
}

.titlebar {
  height: 24px;
  -webkit-app-region: drag;
}

.sidebar {
  background: rgba(000, 000, 000, 0.5);
  width: 100%;
  height: 100vh;
}

.body {
  padding: 8px 12px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-family: 'Gotham Pro', 'Proxima Nova', Arial, serif;
  color: rgba(255, 255, 255, 0.7);
  font-size: 18px;
  cursor: pointer;
}

.item div {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 8px;
}

p {
  margin-bottom: 0;
}

.active {
  color: #3b82f6;
}

form {
  margin-top: 20px;
  display: flex;
  justify-content: space-between;
}

ul {
  padding: 0;
  margin: 0;
  list-style-type: none;
  display: flex;
  flex-direction: column;
}

button {
  box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045);
  line-height: 1.5715;
  border: 0;
  position: relative;
  display: inline-block;
  white-space: nowrap;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  padding: 4px 8px;
  font-size: 16px;
  border-radius: 4px;
  outline: none;
  color: white;
  background: #007aff;
}

input {
  background: transparent;
  width: 100%;
  margin-right: 16px;

  font-size: 16px;
  color: rgba(255, 255, 255, 0.7);
  transition: all 100ms ease-in-out;
  border-radius: 4px;

  outline: none;
  border: 0px;
  border-bottom: 1px solid grey;
}

input::placeholder {
  color: rgba(255, 255, 255, 0.3);
}

      
Aside view after adding styles. Main view's styles have been unchanged.

Step 5: Communicating workspace status via main

At this point, we’ve completed the majority of the work. The main view simply needs to subscribe to a create:todo event and publish a workspace:status event for other views to listen to.

Like before, we’ll import our publish and subscribe helpers from @todesktop/client-ipc. Import the following at the top of main/controller.js:

        import { publish, subscribe } from '@todesktop/client-ipc';

      

Next, we’ll subscribe to the create:todo event. This event will be broadcasted by the spotlight view whenever a todo is created via its spotlight window. On receiving the create:todo event, we’ll proceed to create a todo item, using the title from the broadcasted event.

        constructor(store, view) {
    this.store = store;
    this.view = view;

    const unsubscribe = subscribe("create:todo", ({ title }) => {
      this.addItem(title);
    });

    window.addEventListener("unload", unsubscribe);

		view.bindAddItem(this.addItem.bind(this));

      

Whenever a todo has been marked as completed, we’ll broadcast a workspace:status event. This event will be received by the aside view, which will then be able to render the number of remaining todos for the workspace.

        this.store.count((total, active, completed) => {
  publish("workspace:status", { total, active, completed });

  this.view.setItemsLeft(active);

      

We’ll also add styles to the main.css to make this view presentable:

        html {
  min-height: 100%;
  display: flex;
  justify-content: center;
}

body {
  width: 100%;
}

input.new-todo {
  box-shadow: none;
}

footer,
h1 {
  display: none;
}

.todoapp {
  margin: 8px 16px;
  box-shadow: none;
  background: transparent;
}

.todo-list {
  padding: 0 8px;
}

.view {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.titlebar {
  height: 24px;
  width: 100%;
  -webkit-app-region: drag;
}

      

Main view after adding styles

Main view after adding styles

Step 6: Quickly capturing todos via spotlight

The spotlight view is responsible for publishing create:todo events for the main view. Since it doesn’t need to subscribe to any events, we’ll just need the publish function from @todesktop/client-ipc. Import the following in spotlight/controller.js:

        import { publish } from '@todesktop/client-ipc';

      

Inside of the existing addItem function, we’ll publish a create:todo event with the title of the new todo. This event will be received by main, who will then use it to add a new todo

        addItem(title) {
    publish("create:todo", { title });

    this.store.insert(

      

We’ll also add some styles to spotlight.css:

        html {
  min-height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

body {
  width: 100%;
}

input.new-todo {
  text-align: center;
  padding: 16px;
  margin-bottom: 16px;
  box-shadow: none;
}

footer,
h1 {
  display: none;
}

.todoapp {
  margin: 16px;
  box-shadow: none;
  background: transparent;
}

.todo-list,
section.main {
  display: none !important;
}

.view {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

      

This brings us to the final state of our TodoMVC application. We now have a multi-window app that can:

  • Create, update, and delete todos.
  • Organize todos into different workspaces.
  • Capture todos in a floating window that synchronizes with the main window.
End state of the TodoMVC application

The final steps will be to deploy and release our desktop app.

Step 7: Deploy static app

We have been building our app using http://localhost:8080. This works great for development. However, a public URL is needed if we wish to share it with other users.

We will use Github Pages to deploy the static app and get the public URL. Using Github, navigate to the Settings of your project and then navigate to Pages via the sidebar. Set your branch to main and then click save.

After Github has finished deploying the app, you should see a public URL telling you where your static app is available. This will take a few minutes and you may be required to reload the page. Once the URL is visible, copy it to your clipboard.

Public URL which shows where the static app is available

Using ToDesktop Builder, navigate to Windows and update the following to your copied URL.

Overview

Window URL https://your-github-pages-url

In the index/index.js file, update the production root to to also point to your copied url

        let spotlightWin;

const ROOT =
  process.env.NODE_ENV === "production" ? "https://your-github-pages-url" : "http://localhost:8080";

const background = async () => {

      

Now using your terminal, run the following command:

        npm run build

      

Finally, push your changes to Github

        git commit -am "deploy static app" && git push

      

Step 8: Release Desktop App (Requires Subscription)

To release our desktop app to production for customers to download, press the Release button in ToDesktop Builder. Once a subscription has been created, you will be asked to specify a version number and release your app!

This will open a progress modal which displays the build progress of your Mac, Windows and Linux desktop app. Once completed, you can copy the download URL or download the desktop app directly.

Conclusion

In this guide, you learned how to build and release a TodoMVC desktop app with native functionality. If you ran into issues or have any questions, feel free to raise them on Github or drop us an email at [email protected]