Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 77 additions & 77 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ else on the account. `operations/about` through the rclone rc gives real bytes.

- macOS 14 (Sonoma) or newer. Administrator rights for two commands, listed below.
- A Google account with room to spare.
- Homebrew, to install a release — or Xcode / a Swift 5.9+ toolchain, to build
the app yourself (see below).
- [Homebrew](https://brew.sh).
- Nothing else at runtime. CloudMachine installs its own `rclone` and its own
copy of FUSE-T.

Expand All @@ -119,75 +118,74 @@ so they read the same whoever sends them to you.

### Current limitations

Worth knowing before you start, because none of them announce themselves:

- **One Mac per Google account.** The Drive folder is a constant,
`gdrive:CloudMachine/mac-studio`, whatever the Mac is called, so a second Mac
on the same account would share the folder and the image with the first.
`config/machines.example.json` describes several machines with per-machine
`limit_gb` budgets, but no code enforces those budgets — what is checked is
the real free space on Drive, reported by rclone.
- **Not notarised.** Releases are signed with a self-signed certificate (local
builds ad hoc or with a local one, see below), not with an Apple Developer ID.
The Homebrew cask clears the quarantine flag; a DMG downloaded by hand gets
Gatekeeper's "unidentified developer" warning.
- **Not notarised.** Releases are signed with a self-signed certificate, not
with an Apple Developer ID. The Homebrew cask clears the quarantine flag; a
DMG downloaded by hand gets Gatekeeper's "unidentified developer" warning.
- **Per-machine budgets are not enforced.** `config/machines.example.json`
describes `limit_gb` per Mac, but nothing acts on it — what is checked is the
real free space on Drive, reported by rclone. Several Macs on one account
share that space.

---

## Installing
## Getting started

```sh
brew install --cask renacode/tap/cloudmachine
open -a CloudMachine
```

The cask installs a universal (Apple Silicon + Intel) `CloudMachine.app` into
`/Applications`. `brew upgrade` does not restart the Google Drive mount, and
neither `brew uninstall` nor `--zap` touches the launchd agents or the upload
buffer in `~/.cloudmachine`, which may hold backups that have not reached
Google Drive yet. Run `cloudmachine-agent prepare-shutdown` before
uninstalling. Releases, signing and the cask are described in
[`packaging/README.md`](packaging/README.md).

### Building the app

```sh
cd mac-app
swift run cloudmachine-agent setup-signing-cert # optional, once per Mac
swift run cloudmachine-agent build-app # -> mac-app/build/CloudMachine.app
rm -rf /Applications/CloudMachine.app # never copy over a live bundle
cp -R build/CloudMachine.app /Applications/
```

Removing the installed copy first is not optional. `cp -R` onto an existing
bundle overwrites its files in place; macOS still holds the old signature for
them and kills every agent started from the bundle
(`last exit reason = OS_REASON_CODESIGNING`), while `codesign --verify` keeps
passing. Removed and copied anew, the files get new identities. The mount
survives this: the rclone process that holds it lives outside the bundle.

`build-app` puts the menu-bar app and `cloudmachine-agent` side by side in
`Contents/MacOS/`, with the launchd templates as resources, so the installed app
does not need the repository next to it. It must live in `/Applications`: the
launchd agent that starts the app opens `/Applications/CloudMachine.app`.

`setup-signing-cert` creates a local, self-signed code-signing certificate in the
login keychain. Without it every build is signed ad hoc with a new identity, and
macOS revokes permissions such as Full Disk Access after each rebuild. With it,
`build-app` signs with that certificate automatically.

`swift run cloudmachine-agent make-dmg` packs the built app into
`mac-app/build/CloudMachine-<version>.dmg`; `build-app --universal` builds for
both architectures, as releases do. Releases are built by
`.github/workflows/release.yml` from a `vX.Y.Z` tag. The version
comes from `mac-app/VERSION`, and `cloudmachine-agent version` prints it
together with the build number and the commit the binary was built from.

---

## Setup

`cloudmachine-agent` lives inside the app bundle. `install-launchd` symlinks it
into `/usr/local/bin`; until then, call it by its full path:
CloudMachine lives in the menu bar. Its window opens on a **Required Setup
Steps** card listing what is left to do on this Mac, in order, each with a
button — or, for the two steps an app cannot do, a command to copy:

1. **Install rclone** and **Install FUSE-T.** CloudMachine downloads its own
copies; nothing else is installed system-wide.
2. **Connect Google Drive** — a command to run in Terminal. It opens Google's
sign-in in the browser and waits for your approval. Set up your own OAuth
credentials first (see [below](#your-own-google-oauth-credentials)); the
card for them is at the bottom of the window.
3. **Grant Full Disk Access** — opens the right pane of System Settings.
Without it CloudMachine cannot read when Time Machine last *finished* a
backup, which is the one check that matters.
4. **Install agents** — the launchd agents that keep the Drive mounted, attach
the image and watch the backup.
5. **Create image**, then **Attach image** — the backup image on Google Drive.
The size is a ceiling, not an allocation: the image is sparse, and Drive
only holds what has been written.
6. **Point Time Machine at CloudMachine** — a `sudo tmutil setdestination`
command to copy, because only an administrator can change it.

When the card disappears, setup is done. Turn on automatic backups in System
Settings → General → Time Machine, or run `sudo tmutil enable`.

### Several Macs, one Google account

Each Mac backs up into its own folder, `gdrive:CloudMachine/<folder>`, holding
`<folder>.sparsebundle`. The name is chosen once, when that Mac connects to
Google Drive: by default it comes from the computer name, or pass your own —
`cloudmachine-agent configure-remote --folder office-imac`. It cannot be changed
afterwards, because a new name is a new, empty backup; CloudMachine refuses
rather than orphan the old one. `cloudmachine-agent drive-status` shows the
folder in use. Installations set up before per-Mac folders keep
`mac-studio`, which is where their backup already is.

### Upgrading and uninstalling

`brew upgrade` replaces the app without restarting the Google Drive mount; the
agents pick up the new version on their next run. Neither `brew uninstall` nor
`--zap` touches the launchd agents or the upload buffer in `~/.cloudmachine`,
which may hold backups that have not reached Google Drive yet. Run
`cloudmachine-agent prepare-shutdown` before uninstalling.

Building from source, releases and the measurement harnesses are covered in
[docs/building.md](docs/building.md).

### Setting up from Terminal

The same steps without the app. `cloudmachine-agent` lives inside the app
bundle; `install-launchd` links it into `/usr/local/bin`, so until then call it
by its full path:

```sh
/Applications/CloudMachine.app/Contents/MacOS/cloudmachine-agent --help
Expand All @@ -196,10 +194,10 @@ into `/usr/local/bin`; until then, call it by its full path:
```sh
cloudmachine-agent install-rclone # official binary — the Homebrew build cannot mount
cloudmachine-agent install-fuse # FUSE-T, inside CloudMachine, no separate app
cloudmachine-agent configure-remote # Google OAuth in the browser
cloudmachine-agent create-image --size-gb 4000
cloudmachine-agent configure-remote # Google OAuth in the browser; --folder NAME to choose this Mac's folder
cloudmachine-agent install-launchd # agents that mount Drive and keep it running
cloudmachine-agent create-image --size-gb 4000 # needs the mount from the step above
cloudmachine-agent attach-image
cloudmachine-agent install-launchd # agents that keep it running
```

Two steps need `sudo`, because they change system-wide settings:
Expand Down Expand Up @@ -255,13 +253,25 @@ first and pass `--replace-existing` if you really mean it.

## Running it

Day to day, the menu-bar icon is the whole interface. Its menu shows whether a
backup is running and what is still waiting to upload, with **Back up now** and
**Stop backup**. The window answers one question — is the backup reaching Google
Drive, and if not, why: the time of the last *completed* backup, the state of
the local buffer and the upload, and a red line naming the problem when there is
one. Problems also arrive as macOS notifications, so nothing depends on someone
opening the window.

Everything the window shows is also available in Terminal, for scripts and for
checking over SSH:

```sh
cloudmachine-agent drive-status
```

```
Tools: OK
Drive mount: OK
Drive folder: gdrive:CloudMachine/mac-studio
Image attached: OK (/Volumes/CloudMachine)
Cache on disk: 103 GB of 100G
To upload: ~14 GB (462 items)
Expand Down Expand Up @@ -362,7 +372,7 @@ hours old.

```sh
# Did anything fail today?
grep "^\[$(date +%Y-%m-%d)" ~/Library/Logs/CloudMachine/cloudmachine.log | grep -i awaria
grep "^\[$(date +%Y-%m-%d)" ~/Library/Logs/CloudMachine/cloudmachine.log | grep -i "backup failure"

# Is the upload actually moving, or only erroring? Successes vs refusals per minute.
grep "^$(date +%Y/%m/%d)" ~/.cloudmachine/rclone.log \
Expand Down Expand Up @@ -576,16 +586,6 @@ do it when you can leave the Mac alone.

---

## Measurement harnesses

The measurements behind these decisions — write amplification per band size, and
what survives the cloud layer dying mid-write — are written up in
[gdrive/README.md](gdrive/README.md). The shell harnesses that produced them are
gone; that work now lives as subcommands of `cloudmachine-agent`, which is why
`gdrive/` holds nothing but the write-up.

---

## Licence

MIT. FUSE-T and rclone keep their own licences; see above.
74 changes: 74 additions & 0 deletions docs/building.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Building CloudMachine from source

Most people install with Homebrew (see the [README](../README.md)). This page is
for working on CloudMachine itself: building, running a local build, releases
and the measurement harnesses.

## Requirements

- macOS 14 (Sonoma) or newer
- Xcode, or a Swift 5.9+ toolchain
- `swift-format` for the lint CI runs: `brew install swift-format`

## Build and install a local copy

```sh
cd mac-app
swift run cloudmachine-agent setup-signing-cert # optional, once per Mac
swift run cloudmachine-agent build-app # -> mac-app/build/CloudMachine.app
rm -rf /Applications/CloudMachine.app # never copy over a live bundle
cp -R build/CloudMachine.app /Applications/
```

Removing the installed copy first is not optional. `cp -R` onto an existing
bundle overwrites its files in place; macOS still holds the old signature for
them and kills every agent started from the bundle
(`last exit reason = OS_REASON_CODESIGNING`), while `codesign --verify` keeps
passing. Removed and copied anew, the files get new identities. The mount
survives this: the rclone process that holds it lives outside the bundle.

`build-app` puts the menu-bar app and `cloudmachine-agent` side by side in
`Contents/MacOS/`, with the launchd templates as resources, so the installed app
does not need the repository next to it. It must live in `/Applications`: the
launchd agent that starts the app opens `/Applications/CloudMachine.app`.

`setup-signing-cert` creates a local, self-signed code-signing certificate in the
login keychain. Without it every build is signed ad hoc with a new identity, and
macOS revokes permissions such as Full Disk Access after each rebuild. With it,
`build-app` signs with that certificate automatically. A Homebrew release is
signed with a different certificate, so switching between a local build and a
release needs Full Disk Access granted once more.

`swift run cloudmachine-agent make-dmg` packs the built app into
`mac-app/build/CloudMachine-<version>.dmg`; `build-app --universal` builds for
both architectures, as releases do. The version comes from `mac-app/VERSION`,
and `cloudmachine-agent version` prints it together with the build number and
the commit the binary was built from.

## Tests and lint

```sh
cd mac-app
swift test
swift format lint --strict --recursive Sources Tests
```

`L10nTests` fail on any Polish left outside the Polish translation tables, and
on any `L10n.tr` key without a Polish entry. User-facing text goes through
`L10n.tr("English text")` with a Polish entry in
`Sources/CloudMachineCore/L10nPolish+*.swift`; logs stay English.

## Releases

Releases are built by `.github/workflows/release.yml` from a `vX.Y.Z` tag and
published to Homebrew. The procedure and the one-time setup (signing
certificate, tap token) are in [`packaging/README.md`](../packaging/README.md).

## Measurement harnesses

The measurements behind the design — write amplification per band size, and
what survives the cloud layer dying mid-write — are written up in
[`gdrive/README.md`](../gdrive/README.md). The harnesses that produced them are
the separate `cloudmachine-poc` executable (`swift run cloudmachine-poc --help`).
It is deliberately not part of the app bundle: every harness creates and
deletes disk images.
4 changes: 4 additions & 0 deletions mac-app/Sources/CloudMachineAgent/DriveCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ struct DriveStatus: AsyncParsableCommand {
? "OK" : L10n.tr("missing: %@", readiness.missing.joined(separator: ", "))))
let mounted = DriveBufferService.mountedState()
print(L10n.tr("Drive mount: %@", StatusLines.mounted(mounted)))
print(
L10n.tr(
"Drive folder: %@",
"\(DriveBufferService.remoteName):\(DriveBufferService.remotePath)"))
// Since 26.09.2026 the readability probe has a time limit - which is why
// this tool does not hang on a dead mount (on 25.09.2026 it hung for over
// 25 s and had to be killed), but reports that there is no answer.
Expand Down
10 changes: 9 additions & 1 deletion mac-app/Sources/CloudMachineAgent/SetupCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,18 @@ struct ConfigureRemote: AsyncParsableCommand {
)
var replaceExisting = false

@Option(
name: .long,
help: ArgumentHelp(
L10n.tr(
"Name of this Mac's folder on Google Drive (default: derived from the computer name). Set once; it cannot be changed later."
)))
var folder: String?

func run() async throws {
let (config, key) = await CLIContext.load()
let result = await RemoteConfigurer.connect(
config: config, machineKey: key, replaceExisting: replaceExisting)
config: config, machineKey: key, replaceExisting: replaceExisting, folder: folder)
print(result.message)
if !result.succeeded { throw ExitCode.failure }
}
Expand Down
5 changes: 5 additions & 0 deletions mac-app/Sources/CloudMachineApp/Models/AppStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ final class AppStatus: ObservableObject {
@Published var backupProgress: BackupProgressInfo?
@Published var lastAction: LastRunResult?
@Published var hasFullDiskAccess: Bool = false
/// Whether the mount agent is loaded in launchd; `nil` until asked.
@Published var agentsInstalled: Bool?
/// Whether this Mac's image exists on the mounted Drive; `nil` while the
/// Drive is not mounted, because then nobody can know.
@Published var imageExists: Bool?
@Published var isBusy: Bool = false
@Published var busyLabel: String = ""
@Published var errorMessage: String?
Expand Down
Loading
Loading