Skip to content

Installing

As a container

docker run -d --name arrowloop -p 8422:8422 \
  -v /mnt/user/appdata/arrowloop:/config \
  -v /mnt/user:/data \
  junkerderprovinz/arrowloop:latest

/config holds arrowloop.json, one state database per job, and the run log.

/config has to survive the container

Those state databases are what let the engine tell a new file from a deleted one. Lose them and every job forgets what the two sides agreed on and treats every file on both sides as new: nothing is destroyed, but a great deal is copied, and a deletion made while the record was missing is propagated to nobody.

A first start on an empty /config writes a starter configuration with no jobs, so the interface comes up and can be edited rather than crash-looping on a missing file.

A password for the interface

The container listens on every address, so anyone on the network who reaches port 8422 can start a job. Set a password under Settings, Security, and from then on every page but the login asks for it. Over plain HTTP the session cookie crosses the wire in clear, so a machine reachable from outside wants TLS in front of it either way; see The interface.

Until a password is set, the interface only answers when it is opened on an IP address, localhost, a name without a dot such as tower, or a name ending in .local, .lan, .home, .home.arpa, .internal, .localdomain or .fritz.box. Otherwise a web page anywhere could point a name of its own at this machine and use your browser to drive the interface. Any other name needs the password, or has to be listed in ARROWLOOP_HOSTS, separated by commas; an entry that starts with a dot, such as .example.com, covers every name under it. Behind a reverse proxy, the name that counts is the one the proxy passes on. Caddy and Traefik pass on the name the browser asked for. nginx passes on the name in its proxy_pass line unless it is told proxy_set_header Host $host, so behind a plain proxy_pass http://arrowloop:8422 every request through the proxy gets in without a password; set one there. With or without a password, the interface refuses a change that a page from another site started, and any change not sent as JSON, which is what a page elsewhere could send without the browser asking first.

Without a password, the interface also refuses to download or restore the settings backup or hand out an automatic one, to delete from the trash for good, to forget a job's state and to delete a target, since anybody who reaches the port could otherwise do it. The settings never hand out the Matrix token or the webhook address, with or without a password. The phone app and the desktop window, which nobody else reaches, are not asked.

The same section sets up a second factor: six digits from an authenticator app on top of the password. Setting it up shows eight recovery codes once, and each of them signs in a single time in place of a code.

It also registers passkeys, a key on a phone, a laptop or a security stick used instead of typing the password. A browser offers one only on a host name over HTTPS with a certificate it trusts, or on localhost, so on http://192.168.1.5:8422 the section explains why it cannot. Behind a reverse proxy with a real name they work, and each key belongs to the address it was registered on. A key signs in by itself, so adding one asks for the password, and for a code when the second factor is on. Changing or removing the password removes every passkey, and so does a container that comes back with another ARROWLOOP_PASSWORD_HASH.

All three are kept in /config/security.json, readable by its owner only, and never in arrowloop.json. That file is downloaded whole as a settings backup and replaced whole by a restore, so a hash in it would leave with every backup and restoring an old one would switch the password off.

The password is stored as Argon2id over a value keyed with the install's key, and the second factor's secret is sealed with it, so security.json on its own is no use to whoever copies it. The key comes from ARROWLOOP_APP_KEY, 64 hexadecimal characters such as openssl rand -hex 32 prints, or without it from /config/app.key, drawn on the first start. A copy of all of /config carries that file too, so setting the variable keeps the key out of it. Keep the key: under another one the password and the second factor no longer open, and the container refuses to start until the old key is back or security.json is deleted.

A forgotten password

At the foot of the sign-in form, Forgot password? has ArrowLoop write a one-time code into its log: on Unraid under Docker, ArrowLoop, Logs, elsewhere with docker logs arrowloop. The code stands on its own between the other lines, in yellow. Type it and a new password on the same page. The code is valid for ten minutes, and a wrong code does not spend the right one. After five wrong codes from one address, that address waits a minute before any code is checked again, the right one included. Asking again while it is valid writes nothing, so the log does not fill up and the code you are typing stays good. The new password takes the second factor, every passkey and every other session with it, since any of them could belong to whoever made the reset necessary, and the page says what went.

Whoever can read that log can reset the password this way: root on the host, but also a log viewer such as Dozzle, any container given the Docker socket, even read only, and any log shipper that forwards container output. If any of those is less trusted than you, set the password with ARROWLOOP_PASSWORD_HASH instead, which a code from the log cannot replace.

The environment variable

ARROWLOOP_PASSWORD_HASH still works, and it wins over the password set in the interface, which then shows the password as set from outside and offers no form for it. The hash is made under the install's key, so make it in the running container and hand the result back:

docker exec -it arrowloop arrowloop hash-password -config /config/arrowloop.json

docker run -d --name arrowloop -p 8422:8422 \
  -e ARROWLOOP_PASSWORD_HASH='argon2id$19456$2$1$...' \
  ...

The single quotes matter: the hash is full of $, which a shell would otherwise read as variables. In a Compose file, write every $ as $$. A bcrypt hash, as older versions printed and many other tools make, is still accepted.

Behind a reverse proxy

Five wrong passwords or reset codes from one address lock that address out for a minute. Behind a reverse proxy every request comes from the proxy, so one stranger guessing would lock out everybody, you included. Set ARROWLOOP_TRUSTED_PROXIES to the proxy's address, and the count goes against the address the proxy reports in X-Forwarded-For instead. Several entries are separated by commas. List only proxies you run: a machine on that list could claim a new address for every guess.

When the proxy runs in another container, put the two on a Docker network of their own, give the proxy a fixed address there, and list that one address. Do not list a range such as 172.17.0.0/16 for Docker's default bridge. It includes the bridge's gateway, 172.17.0.1, and connections that Docker's own proxy relays to a published port arrive from that address, so anybody reaching the port that way could choose the address their guesses count against.

If the authenticator app and the recovery codes are both gone, stop the container and delete /config/security.json. That removes the password, the second factor and the passkeys together. A damaged security.json stops the container from starting rather than letting it come up without a password; the log names the file, and deleting it has the same effect.

Jobs from other programs

A file manager such as CastleCommander can hand a pair of folders over as a new job. Give the container a long random token in ARROWLOOP_API_TOKEN, for example what openssl rand -hex 32 prints, and enter the same token in the other program. It sends the token in the X-ArrowLoop-Token header and adds a job with POST /api/jobs:

curl -X POST http://tower:8422/api/jobs \
  -H 'Content-Type: application/json' \
  -H 'X-ArrowLoop-Token: <token>' \
  -d '{"name":"Filme nach Backup","left":"/data/Filme","right":"/data/Backup/Filme","direction":"leftToRight","mode":"mirror","schedule":"0 3 * * *"}'

The job takes the same fields as one in arrowloop.json. Without a state, it gets state/<name>.db. A request with a name that is taken gets a 409, and the job of that name stays as it is. before and after are dropped, as with every save from the interface. The token opens only the job list and this one route, never the settings or the trash, and a request with a wrong token is refused even on an install without a password. A job added this way runs on its schedule like any other, and a mirror deletes at its destination, so keep the token as safe as the password.

On Unraid

ArrowLoop is in Community Applications: search for ArrowLoop in the Apps tab and install it from there. The same template is templates/my-ArrowLoop.xml; dropped into /boot/config/plugins/dockerMan/templates-user/ it appears in the Docker tab's Add Container list, with every field editable. Most installs set the password in the interface; the template's masked password hash field is the environment variable above, and its text says how to fill it from the container's console.

Mount the data path read-write. A two-way job writes to both sides by definition, and a read-only mount produces a job that fails on every run for a reason that reads like a bug.

As a desktop application

Windows, Linux and macOS builds are on the releases page. Windows and Linux get an installer. On Windows it gives ArrowLoop an entry under Apps and installs it for all users under C:\Program Files\ArrowLoop, or in another folder you pick. It asks for an administrator once, while it installs; later updates ask nobody (see Updates). Two switches decide on a Start menu entry and a desktop shortcut, both on at first, and both go to every user on the computer. The next install, silent or not, starts from your answer and removes a shortcut you left out. installer.exe /S installs without a window, and /D=<folder> picks the folder for it.

The installer's first page also asks for a language. ArrowLoop opens in that language the first time, for everyone on the computer, until somebody picks another one inside the app.

The installer also cleans up after older versions. The copy 1.2.0 installed for you alone under AppData\Local\Programs goes, with its shortcuts and its entry under Apps, and so does an installation for all users from 1.1.0 or earlier. It never touches your settings and jobs in %APPDATA%\ArrowLoop. A 1.2.0 installation that you leave alone keeps updating itself where it is; running the new installer once moves it.

The installer asks a running ArrowLoop to quit, as Quit in the tray menu would, so a sync under way stops cleanly. Its last page can start the new version as the person signed in, and a copy that sat in the tray comes back there. A computer with an ARM processor, such as a Snapdragon laptop, takes the one named windows-arm64; the amd64 installer would run there too, but through emulation and slower. The portable.exe beside it is the same program, run from wherever you put it.

Linux gets the program as a single file, linux-amd64 for an Intel or AMD processor and linux-arm64 for a 64-bit ARM one, and an installer for each. It needs WebKitGTK 4.1 (libwebkit2gtk-4.1-0 on Debian and Ubuntu) for its window. The installer is for you alone and asks for no password. It puts ArrowLoop in ~/.local/share/com.junkerderprovinz.arrowloop and adds it to the application menu, with a desktop icon if you want one. Right-clicking the menu entry offers to uninstall it. That copy updates itself like the single file. Make the download executable first (chmod +x arrowloop-linux-amd64-installer).

It is not a client talking to a server. The scheduler, the run log and the API all live in the same process, and the window is a webview pointed at them. Nothing listens on the network at all, which is the difference between a desktop application and a server nobody asked to run.

The schedules run for as long as the window is open. For a machine that should sync while nobody is looking, use arrowloop daemon and arrowloop service instead; see the command line.

As a Flatpak

Each release also carries arrowloop-linux-amd64.flatpak and arrowloop-linux-arm64.flatpak. Open the file with your software centre, or install it in a terminal:

flatpak install --user arrowloop-linux-amd64.flatpak

The Flatpak brings its own WebKitGTK and pulls the GNOME runtime from Flathub, so nothing else needs installing. It never replaces itself, and the Info tile says so where the other copies show Update automatically. A copy from the file updates when you install the next release's file the same way. Its settings and jobs live in ~/.var/app/io.github.junkerderprovinz.arrowloop/config/ArrowLoop.

The sandbox lets ArrowLoop reach your home folder, /media, /run/media and /mnt, which covers your own files and most drives and network shares. A folder anywhere else needs one command before a job can sync it:

flatpak override --user --filesystem=/srv/data io.github.junkerderprovinz.arrowloop

Commands before and after a run run inside the sandbox too, so they find the tools the Flatpak ships rather than the ones on your system.

On Arch Linux and NixOS

On Arch Linux the single file and the installer work once WebKitGTK is installed:

sudo pacman -S webkit2gtk-4.1

NixOS keeps its libraries in the Nix store, not where the single file looks for them, so the file does not start there. The Flatpak does: add services.flatpak.enable = true; to your configuration, rebuild, and install the file as shown above. The container runs on NixOS too.

Starting with the system

Start with the system, on the General tile under Settings, registers ArrowLoop to start when you sign in. It is a per-user entry (a value under HKCU on Windows, a .desktop file under ~/.config/autostart on Linux, a LaunchAgent in your own Library on macOS), so it needs no administrator rights and affects nobody else on the machine. The Flatpak asks the desktop for it through the Background portal, which writes the entry for it, and GNOME may ask you once to allow it.

Started this way, ArrowLoop stays in the notification area, because nobody is waiting for a window when the session starts. It also comes back there after something outside ended it while its window was in the notification area, as an update does. Starting it by hand opens the window, and so does every start after you quit it.

The switch reads its state back from the system rather than from a settings file, so removing the entry with the Task Manager's own Startup tab turns the switch off too, instead of leaving it claiming something that has stopped being true.

Autostart on its own only gets the program running. A job that should sync because the machine just came on wants runAtStart as well, or it will sit there until its schedule is next due; see the configuration reference.

The notification area

It puts an icon in the notification area. A click on it opens a small window beside the icon with what is running now, the last five runs, and buttons to pause, to sync everything and to open ArrowLoop. A second click or a click elsewhere closes it, and a double click opens the main window. The right-click menu has:

  • Open, which brings the main window back.
  • Force sync, which starts every job straight away, as if you had started each one by hand, so no pause and no battery setting holds it.
  • Pause sync or Resume sync. A pause holds every scheduled and watching run until you resume, stops the runs already going, and stays after a restart. The icon turns grey with a pause badge while it lasts.
  • Quit.

The menu and the small window speak the language the interface is set to.

The This computer card on the General tile decides what the window buttons do: whether closing quits or hides, and whether minimising goes to the taskbar or to the notification area. Closing quits by default, because a close button that quietly leaves a program running is the kind of surprise somebody finds a week later. Both choices switch off together with the icon, since a window that hides with nothing left to bring it back is gone. Switching the icon off or on applies at once.

Starting it a second time does not start a second copy. It brings the running window back, which is what a second double-click means when the first one is sitting in the notification area.

The shell is Wails v3, whose own notification area icon can carry a window. The icon is built at startup around the same PNG the window uses rather than committed as a second file, because a second file is one more thing to update when the logo changes, and forgetting it leaves the window and the notification area with different marks.

Of the three desktop builds, only the Windows one is tested by hand; the macOS and Linux builds are compiled but untested.

Updates

The desktop app updates itself from GitHub. It asks for the newest release, downloads the file for your system in the background and checks it against the release's checksums.txt. The new version starts the next time you start ArrowLoop, so a sync that is running is never cut off, and a note in the corner of the window says when it is ready. Pre-releases are never installed. Update automatically, in the Version card on the Info tile under Settings, turns this off; it is on from the start.

A container cannot replace itself and is updated the way it was deployed. There the same switch is off from the start and only reports: switched on, the container asks GitHub once a day for a newer release, names it under ArrowLoop in the Version card and writes it to its log. The phone updates through its store or a new APK, and the Flatpak through Flatpak (see As a Flatpak). Neither shows the switch.

The installed copy on Windows cannot replace itself, since no user may write to its folder. A scheduled task named ArrowLoop Update does it instead, once a day and five minutes after the computer starts, whether ArrowLoop is open or not. It replaces ArrowLoop.exe, sets the version shown under Apps and writes what it did, or why it did not, to %ProgramData%\ArrowLoop\update.log. There is one installation, so the switch applies to everyone on the computer: it lives in %ProgramData%\ArrowLoop\settings.json, which every user may change, and the task skips its run while it is off. An open window notices within a few minutes when the task has put a new version in place and shows the note. The replaced program waits beside the new one as ArrowLoop.exe.old until a later run removes it.

A portable copy, such as the arrowloop-windows-amd64-portable.exe from the releases page, updates itself while it runs: a minute after it starts and once a day after that, in its own folder, and it stays portable. The app on macOS and Linux does the same. A folder it cannot write to, or a macOS app your account cannot change, stays as it is. Its log is update.log beside the configuration file.

Why the task runs as SYSTEM

Program Files belongs to the administrators. A program that replaces itself there without asking anyone needs an account that may write there, and a scheduled task under the system account is how Windows provides one; Firefox's maintenance service and Chrome's updater task work the same way. The task writes to three places and nowhere else: the installation folder, usually C:\Program Files\ArrowLoop; the entry under Apps, in HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\ArrowLoop; and %ProgramData%\ArrowLoop, for its log. It reads nothing from any user's profile. The folder under ProgramData belongs to the administrators, and only settings.json in it is open to other users. If the installer cannot set the folder up that way, it sets up no task. The installer gives the installation folder the same rights as Program Files. Together that leaves no file a user could swap for one the system account would run or write to.

You can install into another folder too. Many second drives let everyone rename what sits at their top, and whoever can rename a folder above the installation can swap it. On such a drive the task runs as the person who installed ArrowLoop, without administrator rights, and that person may change the installation folder. Updates arrive the same way; only the version under Apps stays at the one first installed. The task downloads only from GitHub over HTTPS and installs a file only when it matches checksums.txt.

Uninstalling

Apps, ArrowLoop, Uninstall asks for an administrator and removes the program, its shortcuts and its entry, the scheduled task and %ProgramData%\ArrowLoop with the switch and the log. The webview cache of the person uninstalling goes as well. Every user's settings and jobs in %APPDATA%\ArrowLoop stay, so a later install picks them up.

Trying it locally

A build from source is not a release and never updates itself. Only a build of a version tag, stamped by scripts/desktop.mjs as the release workflow makes it, does. Built with -tags updatetest, the app reads ARROWLOOP_UPDATE_API, a stand-in for https://api.github.com, and ARROWLOOP_UPDATE_DELAY, the wait before the first check (such as 15s), so the whole path can be tried locally. node scripts/desktop.mjs --installer --updatetest builds an installer for such a build. It installs as ArrowLoop Test, with a task and a folder under ProgramData of that name, beside a real installation. Its task starts without your environment and reads the stand-in's address from updatetest-api in %ProgramData%\ArrowLoop Test. Release builds leave the tag out, so nothing on the computer can change where an update comes from.

The builds are not signed

Windows shows its blue warning on first start (More info, then Run anyway), and macOS needs a right-click and Open the first time. That is a deliberate trade for now: a certificate is a recurring cost, and it is worth paying once there are users to pay it for. For the same reason an update is checked only against the release's checksums.txt, which proves the file is the one the release published, not who built it.

On Android

The Android app runs the engine on the phone itself, so the phone's own files can take part in a job. Every release carries the arm64 APK, arrowloop-android-arm64.apk, which is what phones run; an x86_64 build for the Android emulator is in the artifacts of each CI run.

The APK is signed with the project's own key, so a later release installs over an earlier one. An APK from an ordinary CI run carries the public debug key instead, and has to be uninstalled before a release can be installed over it.

What the app keeps on the phone and what it sends where is in the privacy policy.

As a single binary

go build ./cmd/arrowloop

The result needs nothing else installed. It carries the interface, the engine and every storage backend it supports.

Syncing between two places over the internet

ArrowLoop connects to the other side itself, over SFTP, SMB, WebDAV or a cloud's own interface. It has no relay and no account of its own, so a laptop away from home reaches the server at home only if that server can be reached. Opening its SSH port to the internet is the way not to do that.

Put both machines in one private network instead:

  • Tailscale is the quickest. Install it on both machines and they reach each other by name, from anywhere, with no port forwarded. It needs a Tailscale account, its coordination servers introduce the machines, and its relays carry the traffic when no direct path exists. That is the same trade Syncthing and Resilio make, taken once for every program rather than per program. Headscale runs the coordination on your own server.
  • WireGuard on its own needs nobody else's server, but one UDP port forwarded on the router at home.

Then add the server in ArrowLoop as usual, under Targets, Add a server or share, with its name or address inside that network as the host: with Tailscale, the machine name it shows (server or server.your-tailnet.ts.net); with WireGuard, the address the tunnel gives it. A job between the laptop and that target then runs the same at home and away. While the laptop has no connection, its scheduled runs fail and say so, and the next one that gets through catches up.