Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
14efe80
Improve CI - signing timeouts, split from build. Bump to 0.2.1
valicm Oct 21, 2026
cd8de83
Fix for e2e
valicm Oct 21, 2026
86ba137
Merge pull request #7 from Vallic/ci-fixes
valicm Oct 21, 2026
bd52fb0
Item history: versioned items and signed deletions
valicm Oct 21, 2026
ac0e0e4
Updates: Install waits for a running check
valicm Oct 21, 2026
c4d50bd
Merge pull request #8 from Vallic/item-history-tighten
valicm Oct 21, 2026
586ea2c
Teams: say when a team waits for two-step verification
valicm Oct 21, 2026
73ab2f0
First screen: new account, invites, suggested passwords; Hosts as home
valicm Oct 21, 2026
eae1d56
Lists and team pages redesigned
valicm Oct 21, 2026
52760da
Fixes
valicm Oct 21, 2026
ccd8cc4
UI fixes
valicm Oct 21, 2026
02590b6
UI fixes & re-alignments
valicm Oct 21, 2026
ea89815
Merge pull request #9 from Vallic/ui-improvements
valicm Oct 22, 2026
2d3b1ea
Feeds: hosts and snippets from a URL; README demo
valicm Oct 22, 2026
2020b54
Merge pull request #10 from Vallic/webhook-implementation
valicm Oct 22, 2026
61a2e9b
About links; monkey tests
valicm Oct 22, 2026
b1fe851
Merge pull request #11 from Vallic/testing-fixes
valicm Oct 22, 2026
86767bf
Sync offered a week after setup; Android monkey in CI
valicm Oct 22, 2026
e3bb92b
Fix for Android monkey test
valicm Oct 22, 2026
0ebd589
Workspace keeps a page opened as the vault loads; basic Android monke…
valicm Oct 22, 2026
0efb9d5
Merge pull request #12 from Vallic/sign-in-change-android-monkey
valicm Oct 22, 2026
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
8 changes: 5 additions & 3 deletions .github/scripts/need-packages.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ for spec in "$@"; do
installed "$spec" || missing+=("$spec")
done
[ ${#missing[@]} -eq 0 ] && exit 0
apt="sudo apt-get -o DPkg::Lock::Timeout=600"
$apt update -q
apt="sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3"
# A mirror that stalls mid-download never times out by itself (it held a job for an hour once):
# an update gets 10 minutes, and a second try.
timeout 600 $apt update -q || timeout 600 $apt update -q
install=()
for spec in "${missing[@]}"; do
IFS='|' read -ra names <<<"$spec"
Expand All @@ -35,4 +37,4 @@ for spec in "${missing[@]}"; do
install+=("$pick")
done
echo "Installing: ${install[*]}"
$apt install -yq --no-install-recommends "${install[@]}"
timeout 1200 $apt install -yq --no-install-recommends "${install[@]}"
91 changes: 90 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ name: CI
# macOS and Windows builds, the slow ones, only run for pull requests into main and manual runs.
# The branch rules require these checks by name: "Lint, test, live SSH and VPN", "End to end
# (Linux)", "Android build", "Desktop" and "Signed off"; renaming a job means changing the
# rules too.
# rules too. "Android monkey" isn't required: a random walk can find a bug the pull request
# didn't make.
on:
pull_request:
branches: [develop, main]
workflow_dispatch:
inputs:
monkey_seed:
description: "Android monkey: the seed to repeat (empty for a random one)"
required: false
default: ""

concurrency:
group: ci-${{ github.ref }}
Expand Down Expand Up @@ -303,3 +309,86 @@ jobs:
version=$(sed -n 's/^version = "\(.*\)"/\1/p' ../../Cargo.toml | head -1)
npx tauri android build --apk --target aarch64 --config "{\"version\":\"$version\"}"

# Android's monkey on the app in an emulator, kept basic: 3 rounds of 500 random touches,
# swipes, turns and keys, each from a fresh start, over a vault with hosts, keys and a snippet. A
# crash, a Rust panic, an error the page throws or an app that stops drawing fails it. The
# emulator draws in software, a frame taking seconds, so ANRs are noted rather than failing it,
# and the screen is smaller (720 × 1600, as wide in dp as a Pixel 6). The seed is in the log (Run
# workflow takes one to repeat). A debug build, whose WebView can be driven:
# apps/desktop/e2e/README.md.
android-monkey:
name: Android monkey
runs-on: ubuntu-24.04
timeout-minutes: 75
steps:
- uses: actions/checkout@v7

- name: C compiler
run: .github/scripts/need-packages.sh build-essential pkg-config unzip

- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21

- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-linux-android

- uses: Swatinem/rust-cache@v2

- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
cache-dependency-path: apps/desktop/package-lock.json

- name: Android NDK
run: echo "NDK_HOME=$ANDROID_NDK_LATEST_HOME" >>"$GITHUB_ENV"

# For the emulator's processor. Line numbers are all a panic needs from debug info, and
# keep the APK small enough to install.
- name: Build
working-directory: apps/desktop
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
run: |
npm ci
npx tauri android build --debug --apk --target x86_64

- name: KVM for the emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Run
uses: reactivecircus/android-emulator-runner@v2
env:
MONKEY_SEED: ${{ inputs.monkey_seed }}
MONKEY_ROUNDS: 3
MONKEY_EVENTS: 500
MONKEY_ANR: note
with:
api-level: 35
target: google_apis
arch: x86_64
profile: pixel_6
cores: 4
ram-size: 4096M
disk-size: 6000M
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none
script: |
"$ANDROID_HOME/platform-tools/adb" shell wm size 720x1600
"$ANDROID_HOME/platform-tools/adb" shell wm density 280
ADB="$ANDROID_HOME/platform-tools/adb" node apps/desktop/e2e/android-monkey.mjs apps/desktop/src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk target/e2e/android-monkey

- name: Screenshots and logs
if: always()
uses: actions/upload-artifact@v7
with:
name: android-monkey
path: target/e2e/android-monkey
retention-days: 7
if-no-files-found: ignore
120 changes: 85 additions & 35 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ jobs:
# The signing itself authenticates from the AZURE_* variables; this
# `az` lets the login through. And Tauri shows only the stderr of a sign command that
# fails, while the signing library says why on stdout: sign.cmd sends it to stderr.
# Signing needs Microsoft's timestamp server, which doesn't always answer: sign.cmd tries
# four times, 20 seconds apart, rather than fail the build.
- name: Windows signing setup
if: runner.os == 'Windows' && vars.ARTIFACT_SIGNING_PROFILE != ''
shell: pwsh
Expand All @@ -252,8 +254,16 @@ jobs:
$az = "$env:RUNNER_TEMP\az-login.cmd"
Set-Content -Path $az -Encoding ascii -Value '@call "C:\Program Files\Microsoft SDKs\Azure\CLI2\wbin\az.cmd" %* --allow-no-subscriptions'
"AZURE_CLI_PATH=$az" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Set-Content -Path "$env:RUNNER_TEMP\sign.cmd" -Encoding ascii `
-Value "@artifact-signing-cli -e $env:ENDPOINT -a $env:ACCOUNT -c $env:CERT_PROFILE -d MorphGate %* 1>&2"
$cli = "artifact-signing-cli -e $env:ENDPOINT -a $env:ACCOUNT -c $env:CERT_PROFILE -d MorphGate %* 1>&2"
Set-Content -Path "$env:RUNNER_TEMP\sign.cmd" -Encoding ascii -Value @(
'@echo off',
'for /L %%i in (1,1,4) do (',
" $cli && exit /b 0",
' ping -n 21 127.0.0.1 >nul',
')',
'exit /b 1'
)
Get-Content "$env:RUNNER_TEMP\sign.cmd"

- uses: actions/setup-dotnet@v6
if: runner.os == 'Windows' && vars.ARTIFACT_SIGNING_PROFILE != ''
Expand Down Expand Up @@ -369,52 +379,92 @@ jobs:
}
if ($files.Count -lt 3 -or $bad) { Write-Error "$bad of $($files.Count) files aren't signed and timestamped"; exit 1 }

# Tauri notarizes the app; the DMG it's downloaded in is notarized here too (Apple's advice:
# the outermost container), stapled, and put in the release in place of the one uploaded.
# Updates use the .app.tar.gz, which this leaves alone.
- name: Notarize the DMG
if: runner.os == 'macOS' && env.APPLE_API_KEY != ''
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.plan.outputs.release_id }}
run: |
dmg=$(ls target/universal-apple-darwin/release/bundle/dmg/*.dmg | head -1)
status=$(xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" --wait --output-format json | jq -r .status)
[ "$status" = Accepted ] || { echo "::error::notarizing the DMG: $status"; exit 1; }
xcrun stapler staple "$dmg"
asset=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets" --paginate \
--jq '.[] | select(.name | endswith(".dmg")) | "\(.id) \(.name)"' | head -1)
[ -n "$asset" ] || { echo "::error::the release has no DMG to replace"; exit 1; }
gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/assets/${asset%% *}"
gh api -X POST -H "Content-Type: application/x-apple-diskimage" \
"https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=${asset#* }" \
--input "$dmg" --jq .browser_download_url

# The proof, in the log: signed with the Developer ID (hardened runtime, secure timestamp),
# notarized with the ticket stapled, and accepted by Gatekeeper as a downloaded app would be.
# The proof, in the log: the app signed with the Developer ID (hardened runtime, secure
# timestamp), notarized by Tauri with the ticket stapled, and accepted by Gatekeeper as a
# downloaded app would be. The DMG is the macOS notarize job's.
- name: Check the macOS signature
if: runner.os == 'macOS' && env.APPLE_SIGNING_IDENTITY != ''
shell: bash
run: |
bundle=target/universal-apple-darwin/release/bundle
app=$(ls -d "$bundle"/macos/*.app | head -1)
app=$(ls -d target/universal-apple-darwin/release/bundle/macos/*.app | head -1)
codesign --verify --deep --strict --verbose=2 "$app"
codesign -dv --verbose=4 "$app" 2>&1 | grep -E "^(Identifier|Authority|TeamIdentifier|Timestamp|CodeDirectory)"
plutil -p "$app/Contents/Info.plist" | grep -E "CFBundleIdentifier|NSLocalNetworkUsageDescription|LSMinimumSystemVersion"
if [ -n "${APPLE_API_KEY:-}" ]; then
xcrun stapler validate "$app"
spctl --assess --type execute --verbose=4 "$app"
fi
for dmg in "$bundle"/dmg/*.dmg; do
codesign -dv --verbose=2 "$dmg" 2>&1 | grep -E "^Authority"
if [ -n "${APPLE_API_KEY:-}" ]; then
xcrun stapler validate "$dmg"
spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg"
fi

# The DMG goes on to the macOS notarize job, as the iOS app goes on to TestFlight.
- uses: actions/upload-artifact@v7
if: runner.os == 'macOS' && env.APPLE_API_KEY != ''
with:
name: macos-dmg
path: target/universal-apple-darwin/release/bundle/dmg/*.dmg
retention-days: 7
if-no-files-found: error

# The DMG the app is downloaded in, notarized too (Apple's advice: the outermost container),
# stapled, checked and put in the release in place of the one the build uploaded. Updates use
# the .app.tar.gz, which this leaves alone. Apart from the build because this is the part that
# talks to Apple's services, which drop a connection now and then: re-running it takes a
# minute or two, not a new build.
macos-notarize:
name: macOS notarize
needs: [plan, desktop]
if: >-
needs.plan.outputs.build == 'true' && !cancelled() && vars.APPLE_API_KEY != ''
&& contains(needs.plan.outputs.matrix, 'macos')
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/download-artifact@v8
with:
name: macos-dmg
path: ${{ runner.temp }}/dmg

- name: Notarize and staple
env:
API_KEY: ${{ vars.APPLE_API_KEY }}
API_ISSUER: ${{ vars.APPLE_API_ISSUER }}
API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
dmg=$(ls "$RUNNER_TEMP"/dmg/*.dmg | head -1)
key="$RUNNER_TEMP/AuthKey_$API_KEY.p8"
printf '%s\n' "$API_KEY_P8" > "$key"
status=$(xcrun notarytool submit "$dmg" --key "$key" --key-id "$API_KEY" \
--issuer "$API_ISSUER" --wait --output-format json | jq -r .status)
[ "$status" = Accepted ] || { echo "::error::notarizing the DMG: $status"; exit 1; }
# Stapling fetches the ticket from Apple's servers: tried a few times.
for i in 1 2 3 4 5; do
xcrun stapler staple "$dmg" && break
[ "$i" = 5 ] && { echo "::error::stapling the DMG failed 5 times"; exit 1; }
sleep $((i * 20))
done

# The proof, in the log: signed, notarized with the ticket stapled, and accepted by
# Gatekeeper as a downloaded disk image would be.
- name: Check the DMG
run: |
dmg=$(ls "$RUNNER_TEMP"/dmg/*.dmg | head -1)
codesign -dv --verbose=2 "$dmg" 2>&1 | grep -E "^Authority"
xcrun stapler validate "$dmg"
spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg"

- name: Put it in the release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.plan.outputs.release_id }}
run: |
dmg=$(ls "$RUNNER_TEMP"/dmg/*.dmg | head -1)
asset=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets" --paginate \
--jq '.[] | select(.name | endswith(".dmg")) | "\(.id) \(.name)"' | head -1)
[ -n "$asset" ] || { echo "::error::the release has no DMG to replace"; exit 1; }
gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/assets/${asset%% *}"
gh api -X POST -H "Content-Type: application/x-apple-diskimage" \
"https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=${asset#* }" \
--input "$dmg" --jq .browser_download_url

android:
name: Android
needs: plan
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,46 @@
Each `## X.Y.Z` section becomes the notes of that release, on GitHub and in the app's update
prompt. Write them for users.

## 0.2.1

- The first screen offers New vault, New account and Sign in. New account creates your vault and
your sync account in one go.
- An invite to a team opened before you've set MorphGate up shows on its first screen, with your
address filled in, and once your account exists Teams opens with the invite. You can also paste
an invite there (Joining a team?). An invite opened while sync is off opens as soon as you turn
sync on: no need to open the link again.
- A new vault no longer asks at once whether to turn on sync (the first screen offers New account
and Sign in). If sync is still off a week later, MorphGate asks once.
- MorphGate can suggest a strong master password: five random words, wherever you set one.
- With saved hosts, MorphGate opens on your Hosts page, with Quick connect beside New host.
- Hosts, groups, keys, snippets, tunnels and VPNs are tables you can search and filter, each row's
actions shown as you point at it. Hosts show whether they're connected instead of a letter, each
group says what it gives its hosts (which sit indented under it), and the sidebar lists your
open sessions first. Keys, identities and host CAs are tabs of the Keys page. Session logs can
be searched and narrowed to a vault, group or host.
- A team's page starts with what needs you (a member to confirm, a device to approve, a key to
rotate), then has tabs for its members, vaults, your SSH keys, settings and activity. New vault
and the plan sit beside Invite people, and you can invite several people at once. Teams says
per team what's waiting for you.
- When a team requires two-step verification and your account doesn't have it, MorphGate says so
and takes you to turning it on, instead of the team's vaults silently not appearing.
- Sync: every change now carries a version inside its signed envelope, and deletions are signed,
so a sync server can't give a device an older copy of an item than one it has seen. Older
versions of the app keep syncing alongside this one.
- Install and restart waits for an update check that's still running, so it installs the update
that check found.
- Feeds: hosts and snippets from an address your team publishes (Hosts → Add from… → Feed URL…),
read with a token. They stay in step with it on their own, every 15 minutes; your renames, keys,
tags and notes stay yours. A feed can list each server's host key, so new servers connect
without asking you to trust them, and a server with another key is refused. Its snippets are
read-only, and when the feed changes one, Run and Paste show you the new commands first.
- Two devices refreshing the same cloud source at once no longer make duplicate hosts or
"(conflict)" copies.
- About links to the documentation (morphgate.com/docs) and the source code on GitHub.
- Copy buttons (a public key, the recovery key, an invite code, host key details) fall back to
another way of copying when the system refuses the first, and say "Couldn't copy" instead of
doing nothing when neither works.

## 0.2.0

First release.
Expand Down
Loading
Loading