Modules
Per-module settings live under [modules.<name>]. These are global and shared across all panels.
Weather
Section titled “Weather”[modules.weather]unit = "fahrenheit"refresh_minutes = 30| Key | Type | Default | Description |
|---|---|---|---|
unit |
string | "fahrenheit" |
"celsius" or "fahrenheit" |
refresh_minutes |
int | 30 |
How often to re-fetch weather (minutes) |
Battery
Section titled “Battery”[modules.battery]poll_seconds = 30| Key | Type | Default | Description |
|---|---|---|---|
poll_seconds |
int | 30 |
Polling interval for battery status (seconds) |
[modules.clock]show_date = truetimer_sound = "ding"timer_volume = 0.7| Key | Type | Default | Description |
|---|---|---|---|
show_date |
bool | true |
Whether to show the date alongside time |
timer_sound |
string | "ding" |
What rings when a countdown reaches zero — see below. |
timer_volume |
float | 0.7 |
How loud it rings, from 0 to 1. Clamped. |
When a timer is up
Section titled “When a timer is up”The shell’s timers live behind the clock: the readout appears beside the time
while one runs, the popup under it pauses and stops them, and the calendar
overlay’s Timers & stopwatches section is where one is started. So the keys
that decide what a finished countdown sounds like are [modules.clock] keys.
A countdown reaching zero does two things, and it does both every time:
- It rings.
timer_soundis the alarm, and it is what reaches somebody who has walked away from the screen — which is most of the point of setting a timer. - It posts a notification. Timer finished, with the length that ran out, and no timeout on it: it stays on the notification panel until you dismiss it. A sound that has already played tells somebody who missed it nothing, so the notification is what is still there when they come back.
The notification deliberately does not also play the notification
chime, whatever that is set to — the timer has already made its
own noise, and answering one event with two sounds a frame apart is not an
announcement, it is a collision. Setting timer_sound = "none" means a finished
timer is silent and notified, not silent and chimed.
timer_sound takes four kinds of answer, tried in this order:
- One of the shipped alarms —
ding,ding-dong,alarmorgong. These are not files: the shell synthesises them from a handful of numbers and writes the result into~/.cache/moonswing/sounds/the first time one is wanted, which is what lets them work identically underflutter run, amake installand the snap. It is also what makes them shippable — a recorded kitchen timer is somebody’s sample and somebody’s licence, and these are arithmetic under this project’s own GPL-3.0.dingis the default, one clear strike left to ring;ding-dongis two notes falling, like a doorbell;alarmis two bursts of three quick tones for somebody in the next room; andgongis one low strike with a long tail. none(oroff, orsilent) — a finished timer notifies without a sound.- A path — anything with a
/in it,~included. Any format mpv can open. - A name from the system’s sound theme — anything else, looked for under the
XDG sound directories exactly as the notification chime is.
timer_sound = "complete"finds/usr/share/sounds/freedesktop/stereo/complete.oga.
A name that resolves to nothing is not silence: the calendar overlay’s timers section says so and names what it could not find. The notification arrives either way, which is why it needs saying.
Several countdowns set to the same minute ring once, not once each — the alarm is longer than a chime and three of them over each other is a noise. Each one still posts its own notification, so nothing about which timers finished is lost.
Media Player
Section titled “Media Player”[modules.media_player]max_text_width = 200.0| Key | Type | Default | Description |
|---|---|---|---|
max_text_width |
float | 200.0 |
Width in pixels before text starts scrolling |
Workspaces
Section titled “Workspaces”[modules.workspaces]show_app_icons = trueicon_size = 14max_icons = 4show_policy_toggle = true| Key | Type | Default | Description |
|---|---|---|---|
show_app_icons |
bool | true |
Show the icons of the applications open on each workspace |
icon_size |
int | 14 |
Icon size in pixels (8–64) |
max_icons |
int | 4 |
Icons one workspace shows before the rest collapse into a +N (1–16) |
show_policy_toggle |
bool | true |
Put the tiling/floating switch in a workspace’s right-click menu |
With show_app_icons on, each workspace button carries its number or name
and the icons of what is open on it, so the buttons are no longer all the
same width — a workspace holding three windows is wider than an empty one. The
icons come from Miracle’s window tree, and an application is drawn once however
many of its windows are on the workspace.
The tree is re-read in response to Miracle’s own window, workspace and
output events — never on a timer — and only the window changes that can move
a window between workspaces count, so switching focus costs nothing. Nothing
is read at all while show_app_icons is off, and one reader serves every panel
on every monitor.
Right-clicking a workspace button opens a menu for that workspace — not the focused one, whichever button was clicked. It has two things on it.
With show_policy_toggle on, the first two rows are the workspace’s window
placement: Tile new windows and Float new windows, with the one it is
currently in marked. Choosing the other sends Miracle
workspace <n> policy tile|float, which changes where the next window opens —
whatever is already on the workspace stays where it is. Like the urgency flash it
costs no extra round-trip: Miracle reports the policy on the GET_WORKSPACES
entry the button is already built from, so the menu opens already knowing which
row to mark. A Miracle too old to know the command answers with a parse error,
and the mark visibly stays where it was rather than reporting a policy nothing
took.
Move to output… is the second page of the same menu, and lists every other
display Miracle reports — by connector name, with the make and model beside it
where Miracle knows them. On a single-monitor session the row is greyed rather
than hidden. Miracle’s own move workspace to output acts on the focused
workspace and takes no selector, so moving one that is not focused focuses it,
moves it and puts the focus back where it was; every hop carries
--no-auto-back-and-forth, or a compositor with that option set would read the
hop back as “go back” and land somewhere else. The list of displays is read once
per connection and refreshed on Miracle’s output event — never on a timer — so
the menu answers out of memory rather than on a round-trip.
[modules.dock]apps = ["firefox", "org.gnome.Nautilus", "kitty", "code"]icon_size = 24| Key | Type | Default | Description |
|---|---|---|---|
apps |
string array | [] |
Desktop entry IDs of apps to show in the dock |
icon_size |
int | 24 |
Icon size in pixels (should fit within panel height) |
Each entry in apps is a desktop file ID (the filename without .desktop). For example, "firefox" corresponds to firefox.desktop, and "org.gnome.Nautilus" corresponds to org.gnome.Nautilus.desktop.
Desktop files are looked up from standard XDG application directories. Apps with missing desktop files are silently skipped.
Network
Section titled “Network”[modules.network]poll_seconds = 10| Key | Type | Default | Description |
|---|---|---|---|
poll_seconds |
int | 10 |
How often to re-check network status (seconds) |
Launcher
Section titled “Launcher”A magnifying-glass button that opens the application launcher — the same overlay the open_launcher shortcut opens (see Shortcuts), and only ever one of them at a time.
[modules.launcher]icon_size = 18| Key | Type | Default | Description |
|---|---|---|---|
icon_size |
int | 18 |
Icon size in pixels |
The launcher itself lists every installed application, ranks them as you type (matching the name, generic name, Keywords=, and desktop ID, in that order of preference), and launches the selected one with Enter. Applications that declare [Desktop Action …] groups — “New Private Window”, “New Document” — show a chevron; hovering it, or pressing Right with the caret at the end of your query, opens those as a submenu.
Typing a calculation shows its result above the application results:
2^10/4 → 256(1+2)*3 → 9sqrt(16)+1 → 5+ - * / ^ % !, parentheses, and the usual functions (sqrt, sin, cos, tan, ln, log, abs, ceil, floor, …) are understood, along with the constants pi and e. A query has to contain both a digit and an operator to be treated as arithmetic, so searching for an application never turns into a calculation.
Escape or a click on the blurred backdrop dismisses the launcher.
This module is in the default bottom panel, but adding it to an existing config is manual — the default config file is only written when none exists. Add "launcher" to a panel’s layout:
[panels.bottom.layout]right = ["media_player", "notifications", "launcher"]The launcher lists the same applications any menu would: those g_app_info_should_show() accepts. An entry with NoDisplay=true, or one restricted with OnlyShowIn=GNOME;, will not appear.
GitHub
Section titled “GitHub”The GitHub mark, the number of unread notifications beside it, and the inbox behind a click: what is waiting for you across every repository you watch, with the reason each thread arrived. Clicking one opens it in your browser and marks it read, the way clicking it on github.com does; the tick beside a row marks it read without opening it, and the double tick in the header marks the whole list.
[modules.github]refresh_seconds = 60show_count = trueparticipating_only = falseinclude_read = falsemark_read_on_open = true| Key | Type | Default | Description |
|---|---|---|---|
refresh_seconds |
int | 60 |
How often the list is re-read (60–3600). GitHub enforces a floor of a minute and may ask for longer |
show_count |
bool | true |
Show the unread count beside the mark |
participating_only |
bool | false |
Only threads you are mentioned in, assigned to or asked to review |
include_read |
bool | false |
Keep threads in the list after they are marked read |
mark_read_on_open |
bool | true |
Mark a notification read as it opens |
client_id |
string | the GitHub CLI’s | The OAuth app the sign-in runs against — see below |
scopes |
string | "notifications" |
What the sign-in asks for — see below |
This module is in the default bottom panel, but adding it to an existing
config is manual — the default config file is only written when none exists.
Add "github" to a panel’s layout:
[panels.bottom.layout]left = ["github"]Signing in
Section titled “Signing in”The GitHub account is linked once for the whole shell, under Settings ›
Accounts — the module’s popup, while signed out, has a button that goes
there. Sign in with GitHub on the GitHub card starts the same device flow
gh auth login uses: the shell asks GitHub for an eight character code, shows
it on the card, and opens github.com/login/device in your browser. You type the
code in there, authorise the app, and the shell takes it from there — your
password is never typed into the shell and never reaches it. Closing the
settings half-way through loses nothing: the code stays on the card until it
expires. Anything else in the shell that reads GitHub uses the same account —
the todo board, for one, shows a link to an issue or pull request by its title.
The access token is written to ~/.local/state/moonswing/github-token
(mode 0600, in a directory created 0700), not into config.toml. Sign out on
the Accounts card deletes it. That signs this machine out; it does not revoke
the authorisation, which is done from
Settings › Applications on github.com
— the key button beside Sign out opens that page.
The consent screen says GitHub CLI, because client_id defaults to that
tool’s public client id — a client id is public by construction, and borrowing
one is what lets this work without every user registering an app. To use your
own instead, register an OAuth app (Developer settings › OAuth Apps) with
Enable Device Flow ticked, and name it:
[modules.github]client_id = "Iv1.0123456789abcdef"scopes is what that sign-in asks for. The default, notifications, is read
access to the notification list plus the two calls that mark a thread read —
nothing else, not the contents of a single repository. Notifications from
private repositories are not included in that; listing them needs repo,
which is full read/write access to every repository you can reach. The same
goes for a todo card’s link into a private repository, which is shown by its
title only with repo:
[modules.github]scopes = "notifications repo"Change either key and the saved token no longer matches what is being asked for: sign out and in again.
How current the list is
Section titled “How current the list is”There is no push channel for notifications — GitHub’s API is polled — so the
module polls it the way the API asks to be polled. Each request carries the
previous response’s validator, so an unchanged list comes back as a 304 that
costs no rate-limit quota, and GitHub’s own X-Poll-Interval is honoured
whenever it asks for longer than refresh_seconds. Nothing is polled at all
while no panel carries the module: one poll for the machine, however many bars
draw the mark.
Copying the sign-in code needs wl-copy (the wl-clipboard package), as the
emoji picker does; without it the code can still be typed out by hand, and the
card says so.
Stock market
Section titled “Stock market”Your watchlist crawling along the bar the way the ticker runs along the bottom
of a financial news channel: each symbol, its price, and its move today in green
or red. Hover the strip to hold it still, or set scroll = false to have it
stand still on the first ticker of the list for good. Click it for the list itself, where you
can search by symbol or company name and add what you find, reorder the list
with the arrows on a row, remove a ticker with its ×, or click a row to open its
page on Yahoo Finance.
Quotes come from Yahoo Finance, read exactly the way
ticker reads them — the same
request, the same session handshake, and the same rule for which price is “the”
price: outside the regular session the strip shows the pre-market or after-hours
price once anything has traded, and the day’s change includes it. The Stock
market desktop widget reads the same quotes from the same poll, so the two never
disagree.
[modules.stocks]symbols = ["^GSPC", "^DJI", "^IXIC", "AAPL", "MSFT", "BTC-USD"]refresh_seconds = 15scroll = truescroll_speed = 40width = 320| Key | Type | Default | Description |
|---|---|---|---|
symbols |
array of strings | [] |
The watchlist, in crawl order, as Yahoo Finance spells each symbol |
refresh_seconds |
int | 15 |
How often prices are re-read while a market on the list is trading (5–3600) |
scroll |
bool | true |
Whether the strip crawls. false shows only the first ticker on the list, standing still |
scroll_speed |
float | 40 |
How fast the strip crawls, in pixels a second (5–400) |
width |
float | 320 |
How wide the crawling strip is in a horizontal bar (80–2000). A watchlist narrower than this sits still |
Symbols are Yahoo Finance’s: AAPL for a US stock, ^GSPC for the S&P 500,
BRK-B for a class of share, SAP.DE for a stock on another exchange,
EURUSD=X for a currency pair, BTC-USD for a coin. Indices are shown by name
and currency pairs as EUR/USD. The popup’s add button writes symbols for you,
and Settings → Shell → Module Settings → Stock market edits the same list.
A new watchlist starts empty; the popup offers the S&P 500, the Dow and the Nasdaq
as a one-click start. While every market on the list is closed, prices are
re-read every two minutes instead of every refresh_seconds, and nothing is
requested at all while neither the strip nor the widget is on screen. In a
left or right bar, where there is no room to crawl, the module is a chart icon
coloured by the first ticker’s move.
The module is not in the default layout — add "stocks" to a panel’s
left, center or right list, or use Settings → Shell → Panels & Layout.
Notifications
Section titled “Notifications”A bell that shakes and shows a count when a notification arrives, and opens the notification panel on click — a full-height surface that sweeps in from the right edge over everything else on screen, and back out the same way when dismissed, listing what has arrived with each notification’s actions, a mark everything read button and a Clear all. The shell is the desktop’s notification daemon, so this is where notifications from every application land.
[modules.notifications]sound = "chime"sound_volume = 0.7| Key | Type | Default | What it does |
|---|---|---|---|
sound |
string | "chime" |
What plays when a notification arrives — see below. |
sound_volume |
float | 0.7 |
How loud it plays, from 0 to 1. Clamped. |
sound takes four kinds of answer, tried in this order:
- One of the shipped sounds —
chime,ping,glass,bellorknock. These are not files: the shell synthesises them from a handful of numbers and writes the result into~/.cache/moonswing/sounds/the first time one is wanted, which is what lets them work identically underflutter run, amake installand the snap.chimeis the default. none(oroff, orsilent) — notifications arrive without a sound.- A path — anything with a
/in it,~included. Any format mpv can open. - A name from the system’s sound theme — anything else. Looked for under the
XDG sound directories (
~/.local/share/sounds,/usr/share/sounds, and whateverXDG_DATA_DIRSnames), both directly and in the<theme>/<profile>/layout thesound-theme-freedesktoppackage installs.sound = "message"finds/usr/share/sounds/freedesktop/stereo/message.oga.
A name that resolves to nothing is not silence: the notification panel’s Notification sound row says so, names the key, and previews whatever is configured when you click it.
Two things the chime deliberately does not do. It does not play while notifications are silenced — silencing is about interruption, and a sound is the most interrupting thing the shell does. And a burst of notifications arriving together plays once rather than once each, which is why the count on the bell is what says how many there were.
The chime belongs to this module, so it plays only while a notifications
module is in one of your panels. With the module in no panel there is nothing
holding it open, and the panel’s sound row says that too.
This module is in the default bottom panel, but adding it to an existing
config is manual — the default config file is only written when none exists.
Add "notifications" to a panel’s layout:
[panels.bottom.layout]right = ["media_player", "notifications", "launcher"]The panel ignores the bars’ exclusive zones and draws on the overlay layer, so it covers the full height of the output and passes over the panels rather than being pushed between them.
Read, and dismissed
Section titled “Read, and dismissed”The panel’s two clearing buttons are not the same act. The ✓✓ button marks everything read: nothing is removed, the messages stay on the list to be read again, and what stops is the shell asking — the bell’s count, the floating card in the corner of every output, and the chime. Clear all empties the list.
Opening the panel does not mark anything read. Reading a notification is something you do, not something a window being mapped does for you.
Sound Control
Section titled “Sound Control”No configurable settings.
System Monitor
Section titled “System Monitor”Drives both the panel module (CPU, memory, and temperature, with a popup) and the System tab in the overlay, which adds usage graphs, swap, load average, network throughput, disk usage, and a sortable process table you can kill from. The two share one sampler, so these settings apply to both.
[modules.system_monitor]poll_seconds = 1temp_unit = "celsius"history_samples = 120cpu_percent_mode = "machine"show_kernel_threads = falseconfirm_kill = truekill_grace_seconds = 5disk_poll_seconds = 30| Key | Type | Default | Description |
|---|---|---|---|
poll_seconds |
int | 1 |
How often stats are re-read (1–60) |
temp_unit |
string | "celsius" |
"celsius" or "fahrenheit" |
history_samples |
int | 120 |
How many samples the graphs keep (10–600). At the default cadence, 120 is two minutes |
cpu_percent_mode |
string | "machine" |
"machine": 0–100% of the whole machine, so the process rows sum to the total. "core": top-style, where 100% is one saturated core |
show_kernel_threads |
bool | false |
Show kernel threads in the process table |
confirm_kill |
bool | true |
Ask before quitting a process. A force quit always confirms regardless |
kill_grace_seconds |
int | 5 |
How long a process gets to honour the request to quit before the row offers to force it (1–60) |
disk_poll_seconds |
int | 30 |
How often filesystem usage is re-read (5–600). Separate because it shells out to df |
Nothing is read until something needs it: the shell only samples /proc while the panel module is on a panel or the System tab is open, and the per-process scan runs only while that tab is actually the visible one.
Two processes can never be killed from here, whatever the config says: the shell itself, and init.
Screenshot
Section titled “Screenshot”The camera in the bar: click it, choose an area, a window or a screen, and the
PNG is on the disk and on the clipboard. The same settings apply to the
screenshot_area shortcut, which takes the shot without the module being in any
panel.
[modules.screenshot]directory = "~/Pictures/Screenshots"filename_prefix = "Screenshot"copy_to_clipboard = truedelay_seconds = 0show_cursor = falseshutter_sound = "shutter"shutter_volume = 0.6| Key | Type | Default | What it does |
|---|---|---|---|
directory |
string | ~/Pictures/Screenshots |
Where the PNG is written. ~ and $HOME are expanded. |
filename_prefix |
string | "Screenshot" |
What each file is named before the date and time. |
copy_to_clipboard |
bool | true |
Also put the shot on the clipboard, via wl-copy. |
delay_seconds |
int | 0 |
A pause before the shutter, for getting a menu on screen first (0–60). |
show_cursor |
bool | false |
Paint the pointer into the frame. |
shutter_sound |
string | "shutter" |
What plays once the file has been written — see below. |
shutter_volume |
float | 0.6 |
How loud it plays, from 0 to 1. Clamped. |
The shutter
Section titled “The shutter”The sound plays when the screenshot has been saved, not when you choose what
to capture — so it follows delay_seconds, and it is the thing that tells you
the file exists. A capture that failed makes no sound, because nothing was
photographed.
shutter_sound takes four kinds of answer, tried in this order:
- One of the shipped shutters —
shutter,snap,clackortick. These are not files: the shell synthesises them from a handful of numbers and writes the result into~/.cache/moonswing/sounds/the first time one is wanted, which is what lets them work identically underflutter run, amake installand the snap. It is also what makes them shippable — a camera click recording is almost always somebody’s licensed sample, and these are arithmetic under this project’s own GPL-3.0, with no third party in them.shutteris the default: a reflex camera, mirror up and mirror down.snapis one bright click,clacka heavier mechanism with the body ringing under it, andticka small dry tick for somebody who wants the confirmation without the theatre. none(oroff, orsilent) — screenshots are saved without a sound.- A path — anything with a
/in it,~included. Any format mpv can open. - A name from the system’s sound theme — anything else, looked for under the
XDG sound directories exactly as the notification chime is.
shutter_sound = "camera-shutter"finds/usr/share/sounds/freedesktop/stereo/camera-shutter.oga.
A name that resolves to nothing is not silence: the screenshot menu says so and names what it could not find. The file is written either way, which is why it needs saying — a shutter that has quietly stopped working looks exactly like one you turned off.
There is deliberately no shutter for a recording. A recording announces itself by the readout in the bar for as long as it runs, and a camera click at the end of one would be describing a photograph nobody took.
Scratchpad
Section titled “Scratchpad”The note in the bar is the window manager’s scratchpad: a place to stash a
window off every workspace and call it back over whatever you are doing. A
click shows what is on the scratchpad, centred on the screen you are on, or
hides it again. Hovering it names the two shortcuts that go with
it — toggle_scratchpad, which does what the click does, and
move_to_scratchpad, which stashes the window you are in. That half has no
button, because clicking the bar is not the window you meant to stash.
The module has no settings of its own. It shows no count of what is stashed, because the window manager does not report one: a stashed window is taken out of its window tree altogether. Every stashed window is shown and hidden together.
If the shell cannot reach the window manager, the icon dims and its label says why; the next click tries again.
The checklist icon opens the todo board on the screen whose bar you clicked. It is a board of five columns — Inbox, Todo, In Progress, Finished and Abandoned — and a card is moved between them by dragging it. The + at the top of a column adds a card there; clicking a card opens it to edit its title, details, column, due date and repetition, and to read its history. Every time a card changes column, the date and time is recorded in that history.
Overdue cards sit at the top of Inbox, Todo and In Progress, above the rest, which keep the order you dragged them into. Finished shows what was finished today; everything finished earlier is folded under the day it was finished, and Abandoned folds every day, today’s included. Click a day to open or close it. While you search, every day holding a match is open.
When the shell starts, and again at each midnight, it posts one notification listing what is due that day, followed by anything still open from an earlier day. The number beside the icon counts the same things. Finished and abandoned cards are never counted.
A card can repeat every so many days, weeks or months, starting on a day you choose. On each scheduled day a copy of it is added to the top of Todo, due that day, and the repetition moves to the copy — so the newest copy is the one to edit or delete to change or stop it. A monthly card started on the 31st lands on the last day of shorter months and goes back to the 31st after them. If the shell was not running on a scheduled day, one copy is made when it next starts, for the most recent day missed.
The search field at the top of the board filters every column down to the cards that match, and highlights what matched. It looks for any piece of text — the middle of a word counts, and case does not — in a card’s title and details; with several words, a card has to contain all of them. Ctrl+F returns to the field, and Escape clears it before it closes the board.
A web address in a card’s title or details is a link: clicking it opens the
address in your browser rather than the card. While a GitHub account is signed
in (Settings › Accounts), a link to a GitHub issue or pull request is drawn as
a chip instead, with its title, its number and an icon for whether it is open, a
draft, merged or closed; hovering over it says which repository it is in, and
clicking it still opens the page. A title is read when the board first shows the
card and read again when the board is opened more than ten minutes later, so
the chips catch up with merges. A link GitHub will not answer for keeps its
number, with a warning icon and the reason on hover — a link into a private
repository needs the repo scope (see GitHub).
The megaphone at the top of the board opens your standup summaries, and
New summary there writes one: what you moved to Finished since the last
summary, everything in In Progress (marked started if it moved there since
then), everything in Todo with its due date, overdue first, and anything you
moved to Abandoned since then. The first time, it covers the last 24 hours.
Opening the megaphone only shows the summaries already taken; it never writes a
new one. It is plain text, for pasting into a chat: select it where it is (drag,
double-click, or Ctrl+A) and copy with Ctrl+C, which needs
wl-clipboard. A web address in a card’s title shows up as a link, and
clicking it opens it.
Inbox is left out, and so are calendar events the Google sync put on the
board — a meeting is not work to report. Nothing is sent anywhere.
The board is kept in an SQLite database, ~/.local/share/moonswing/notes.db
(or under $XDG_DATA_HOME), not in config.toml, and saved as you go. SQLite comes
with the shell, so nothing needs installing. A board saved by an older version
of the shell as todo.json is imported the first time, and the file is renamed
to todo.json.imported rather than deleted. If the board cannot be read, it
says why and refuses to save anything over it until it can. The module has no
settings in config.toml.
Backups
Section titled “Backups”Every change is also written to a plain JSON backup,
~/.local/share/moonswing/backup/todo-backup.json, and the line along the
bottom of the board says where it is. If notes.db is missing when the shell
starts, the board is restored from that file; if it is damaged, it is renamed
to notes.db.damaged-<time> — never deleted — and rebuilt from the file. Either
way the board says so, since anything changed after the backup was last written
is not on it. A database written by a newer version of the shell is not
damaged, and is left alone.
A backup on the same disk goes when the disk does, so keep a copy of that file somewhere else. Its folder holds nothing else, which makes it easy to keep in Git:
cd ~/.local/share/moonswing/backupgit initgit add todo-backup.jsongit commit -m "Todo backup"The file is rewritten only when the board changes, pretty-printed with cards in board order and notes in the order they were made, so each commit is exactly what changed. Replacing the file with a symlink into a repository you already keep works too: it is written through the link.
Backups… at the bottom of the board (or the cloud icon beside the search field) shows where the file is, and Restore from this file… replaces the board with it — for when you have put back an older one — after saving the current board beside it.
Task list sync
Section titled “Task list sync”The board can be kept in step with a task list on a CalDAV server — Radicale, Baïkal, Nextcloud, ownCloud, Fastmail, iCloud and the like — so a phone (DAVx⁵ with jtx Board or Tasks.org) or another computer (Thunderbird, Evolution) can read and change the same cards. The board itself stays where it is: the database above is still what is on screen, it works offline, search still covers every card and note, and the backup file is still written.
Sign in once under Settings › Accounts › CalDAV with the server’s address
(or the address of one calendar or task list), your user name and a password —
an app password, where the server offers them. The shell asks the server for
your calendars and task lists before keeping anything, so a wrong address or
password is said there and then. Add account signs in to another server (or
as another user on the same one) beside it; signing in again with an address
and user name already listed replaces that account rather than listing it
twice. The addresses and passwords are kept in
~/.local/state/moonswing/caldav-account.json (or under $XDG_STATE_HOME),
readable only by you, not in config.toml. The same accounts’ calendars of
events can be shown on the Calendar tab; see Calendar.
The server’s certificate is checked against the system’s trust store
(/etc/ssl/certs). A server that store cannot vouch for — self-signed, signed
by your own CA, or serving its certificate without the intermediate that links
it to a public one — is refused with CERTIFICATE_VERIFY_FAILED, and the form
shows the certificate’s SHA-256 fingerprint. Compare it with
openssl x509 -in cert.pem -noout -fingerprint -sha256 on the server; if they
match, Trust certificate and connect pins that one certificate to the
account (for an account already signed in, the card offers Trust beside
it instead). Only it is trusted, not certificates in general, so a renewed
self-signed certificate is refused again until it is trusted in turn. The
alternative that needs no pin is to make the machine trust the server: have it
serve its full chain, or add your CA to the system trust store
(update-ca-certificates on Debian and Ubuntu, trust anchor on Fedora and
Arch).
Then open Backups… on the board, pick a list and Link. With more than one account signed in, every account’s lists are offered, each named with its account, and the sync signs in with the account the list was found on. Linking merges: every card is sent to the list and every task on it comes to the board. With the switch beside it turned on it replaces the board instead — the cards are removed and the list’s tasks take their place, after the board is saved beside the backup file as a restore does. Unlink stops the syncing and leaves both sides as they are.
What a task list holds of a card is its title, its notes, its column, its due day and a simple repeat. The columns are the task’s status — Todo is needs action, In Progress in process, Finished completed and Abandoned cancelled; an Inbox card is needs action with a marker other apps keep and ignore. A repeat of every so many days, weeks, months or years goes both ways; anything more (say, every Monday and Wednesday) is left on the server as it is, and the card does not repeat on the board. The move history and the order within a column stay on this machine, and everything a task has that a card does not — alarms, categories, another app’s own fields — stays on the server untouched: only the fields that changed are written back. Meetings the Google calendar sync put on the board are never sent.
The board syncs a few seconds after you change it, when you open it, when you press Sync now, and every five minutes while a list is linked — nothing at all when none is. A card changed on both sides since the last sync keeps each side’s change where they touched different fields; where both changed the same field, the server’s version is kept and the board lists the cards it happened to. A task deleted on the server takes its card with it, unless the card was changed here since, in which case it is sent back as a new task; a card deleted here is deleted there, unless the task was changed there since. A server that cannot be reached is shown in red along the bottom of the board, and nothing is lost: whatever did not get through goes on the next sync that does.