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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ prompt. Write them for users.
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

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ These run the real AppImage, deb and rpm binaries, not a dev build, and drive th
| `package.mjs` (deb, rpm) | The binary knows how it was installed, finds the update, and warns that installing asks for an administrator password (installing needs root, so it stops there) |
| `teams.mjs` | Two apps side by side (Alice and Bob) with a local reference sync server: both turn on sync; Alice creates an organization and a vault and invites Bob; Bob joins only after the owner's fingerprint matches, and Alice confirms him only after his matches; a host saved in the shared vault reaches Bob, his edit reaches Alice; Bob publishes his SSH key; Alice revokes him (the vault leaves his device) and rotates the vault key; members may create vaults; the activity log tells the story. Then Alice offboards Bob against the local sshd: removal and key rotation, his key found and removed from `authorized_keys`, and the host's shared key replaced (she still logs in) |
| `layout.mjs` | Turns sync on from the offer a new vault gets, fills every page (hosts in a group, a key, a snippet, a tunnel, a team with an invite to a long address), and at 860, 1024, 1280 and 1600 px wide checks that nothing on a page overlaps or runs out of its column. Closing an editor returns to the page it was opened from, and closing a page to the session under it; with no session open, a page takes the tab bar's place |
| `monkey.mjs` | Only with `E2E_ONLY=monkey`: each package (AppImage, deb, rpm) takes `MONKEY_STEPS` seeded random clicks, typing, keys, selects and resizes over a vault with hosts where nothing listens; any page error, blank page, unanswered call or Rust panic fails it, with the seed and the last steps in `monkey-<package>/failure.txt` (`MONKEY_SEED` repeats it) |
| `demo.mjs` | Only with `E2E_ONLY=demo`: the README's demo. The AppImage's Hosts page, a click on a host and its recorded session (in a demo shell), drawn by `tools/readme-demo/demo-svg.mjs` as `demo/morphgate.svg` (copy it to `assets/demo/morphgate.svg`) |
| `mobile.mjs` | The phone layout in a 390 × 844 window: imports a key and saves a host on the phone screens, connects from the host's sheet, interrupts a command with Ctrl from the extra-key row and repeats one with ↑, keeps the session open behind the host list, browses into a folder over SFTP and downloads a file to Downloads, then locks and unlocks on the phone unlock screen |

Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/e2e/appimage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,13 @@ try {
assert.match(await openAbout(s), new RegExp(`Version ${version} · AppImage`));
ok(`About: ${version}, AppImage`);

// Its documentation and its source code, in the browser (browser.sh notes what it's given).
await s.clickButton("Documentation ↗");
await s.clickButton("Source code on GitHub ↗");
const opened = () => (existsSync(process.env.E2E_BROWSER_LOG) ? readFileSync(process.env.E2E_BROWSER_LOG, "utf8") : "");
await s.wait(() => (/^https:\/\/morphgate\.com\/docs$/m.test(opened()) && /^https:\/\/github\.com\/Vallic\/MorphGate$/m.test(opened())) || null, "the documentation and the source code opened");
ok("About opens the documentation and the source code");

// The third-party notices that come with the app: a crate's, from the generated list.
await s.clickButton("Open-source licenses");
await s.wait(() => s.exec(`return document.querySelector(".licenses-list li") ? true : null`), "the license list");
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/e2e/browser.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
#!/bin/sh
# What the app opens sign-in pages with during e2e (MORPHGATE_BROWSER): follows the redirects
# like a browser, from the identity provider back to the sync server.
exec curl -sSL --max-time 30 -o /dev/null "$1"
# What the app opens web pages with during e2e (MORPHGATE_BROWSER): follows the redirects like a
# browser, from the identity provider back to the sync server. Pages off this computer (the docs,
# GitHub) aren't fetched; every address is noted in E2E_BROWSER_LOG when it's set.
[ -n "${E2E_BROWSER_LOG:-}" ] && echo "$1" >>"$E2E_BROWSER_LOG"
case "$1" in
http://127.0.0.1[:/]* | http://localhost[:/]*) exec curl -sSL --max-time 30 -o /dev/null "$1" ;;
esac
231 changes: 231 additions & 0 deletions apps/desktop/e2e/monkey.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// A monkey test of a packaged build: a vault with hosts, groups, keys and snippets, then a seeded
// random walk through the app (clicks on whatever is there, typing, keys, selects, the window
// resized), checking after every step that the page threw nothing, still renders and still
// answers. A failure names the seed, the step and the last steps taken, with a screenshot.
//
// node monkey.mjs <binary> <out dir> <label>
//
// MONKEY_STEPS (500) and MONKEY_SEED (random, printed) repeat a run. Its hosts point where
// nothing listens (no server a random walk could change files on), and what would end the walk
// or leave the window (locking, installing an update, native file dialogs, erasing the device)
// isn't clicked.

import { mkdirSync, writeFileSync } from "node:fs";
import { createVault, session } from "./webdriver.mjs";

const [app, out, label] = process.argv.slice(2);
const STEPS = Number(process.env.MONKEY_STEPS || 500);
const SEED = Number(process.env.MONKEY_SEED || Math.floor(Math.random() * 2 ** 31));
const PASSWORD = "correct horse battery staple";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
mkdirSync(out, { recursive: true });

// mulberry32: the same seed, the same walk.
let state = SEED >>> 0;
const random = () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const pick = (list) => list[Math.floor(random() * list.length)];
const between = (lo, hi) => lo + Math.floor(random() * (hi - lo + 1));

/** Something a person might type, or a cat walking over the keyboard. */
function gibberish() {
return pick([
() => Array.from({ length: between(1, 12) }, () => pick("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")).join(""),
() => String(between(-99999, 99999)),
() => pick(["web-1", "db.example.com", "10.0.0.1", "::1", "user@host", "~/.ssh/id_ed25519", "https://x.example/feed", "*.example.com", "0", "-1", "65536"]),
() => Array.from({ length: between(1, 20) }, () => String.fromCharCode(between(32, 126))).join(""),
() => pick(["čćžšđ", "日本語のホスト", "émoji 🚀🔑", "‮RTL", "a\u0000b", "<b>bold</b>", "' OR 1=1 --", "${HOME}", "%s%n", "\\\\server\\share"]),
() => "x".repeat(between(200, 2000)),
() => "",
])();
}

// Clicked: anything that takes a click, unless it ends the walk or leaves the window.
const CLICKABLE = "button, a[href], [role=tab], [role=menuitem], [role=radio], input[type=checkbox], summary, .lrow, .row-main, label.check";
const AVOID =
/^(Lock|Install and restart|Quit)\b|lock (now|the vault)|erase|reset (this|the) (device|app)|delete (my|this|your) account|delete account|sign out everywhere|export|save \.cast|save .*csv|example csv|choose (a )?file|choose…|browse…|upload|download|open file|pick a file/i;

const PAGE_HOOKS = `
if (!window.__monkey) {
window.__monkey = [];
window.addEventListener("error", (e) => window.__monkey.push("error: " + (e.message || e.error)));
window.addEventListener("unhandledrejection", (e) => window.__monkey.push("unhandled rejection: " + (e.reason?.message ?? e.reason)));
}
return true;`;

const s = await session(app);
const trail = [];
const counts = {};
let step = 0;

/** What the walk does next, and what it did. */
async function act() {
const roll = random();
if (roll < 0.56) {
// Something visible and enabled to click, by its index among them.
const n = await s.exec(
`const avoid = new RegExp(arguments[1], "i");
window.__targets = [...document.querySelectorAll(arguments[0])].filter((e) => {
const r = e.getBoundingClientRect();
if (!r.width || !r.height || e.disabled || e.closest("[inert], [aria-hidden=true]")) return false;
if (e.matches("input[type=file]") || e.closest("label")?.querySelector("input[type=file]")) return false;
const text = (e.getAttribute("aria-label") || e.innerText || e.title || "").trim();
return !avoid.test(text);
});
return window.__targets.length`,
CLICKABLE,
AVOID.source,
);
if (!n) return "click: nothing to click";
const i = Math.floor(random() * n);
return s.exec(
`const e = window.__targets[arguments[0]];
const what = (e.getAttribute("aria-label") || e.innerText || e.title || e.tagName).trim().replace(/\\s+/g, " ").slice(0, 60);
e.scrollIntoView({ block: "nearest" }); e.click(); return "click: " + what`,
i,
);
}
if (roll < 0.76) {
const text = gibberish();
const typed = await s.exec(
`const fields = [...document.querySelectorAll("input:not([type=checkbox]):not([type=radio]):not([type=file]), textarea")]
.filter((e) => { const r = e.getBoundingClientRect(); return r.width && r.height && !e.disabled && !e.readOnly; });
if (!fields.length) return null;
const e = fields[Math.floor(arguments[1] * fields.length)];
const proto = e instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const value = arguments[2] ? e.value + arguments[0] : arguments[0];
e.focus();
Object.getOwnPropertyDescriptor(proto, "value").set.call(e, value);
e.dispatchEvent(new Event("input", { bubbles: true }));
return (e.getAttribute("aria-label") || e.placeholder || e.name || e.type).slice(0, 40)`,
text,
random(),
random() < 0.3,
);
return typed === null ? "type: no field" : `type into ${typed}: ${JSON.stringify(text.slice(0, 40))}`;
}
if (roll < 0.84) {
const key = pick(["Escape", "Enter", "Tab", "ArrowDown", "ArrowUp", "Backspace", " "]);
return s.exec(
`const e = document.activeElement || document.body;
const init = { key: arguments[0], bubbles: true, cancelable: true };
e.dispatchEvent(new KeyboardEvent("keydown", init));
e.dispatchEvent(new KeyboardEvent("keyup", init));
// Enter in a form submits it, as a browser would.
if (arguments[0] === "Enter" && e.form && e.tagName !== "TEXTAREA") e.form.requestSubmit();
return "key: " + arguments[0] + " on " + (e.getAttribute?.("aria-label") || e.tagName)`,
key,
);
}
if (roll < 0.9) {
return s.exec(
`const all = [...document.querySelectorAll("select")].filter((e) => e.getBoundingClientRect().width && !e.disabled && e.options.length);
if (!all.length) return "select: none";
const e = all[Math.floor(arguments[0] * all.length)];
const o = e.options[Math.floor(arguments[1] * e.options.length)];
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(e, o.value);
e.dispatchEvent(new Event("change", { bubbles: true }));
return "select: " + o.text.slice(0, 40)`,
random(),
random(),
);
}
if (roll < 0.95) {
// Typing into a session (never Enter: no command runs).
const text = gibberish().replace(/[\r\n]/g, "").slice(0, 80) || "\u0003";
const sent = await s.exec(
`const t = document.querySelector(".terminal:not([hidden]) .xterm-helper-textarea");
if (!t) return false;
t.focus(); t.dispatchEvent(new InputEvent("input", { data: arguments[0], inputType: "insertText", bubbles: true })); return true`,
text,
);
return sent ? `terminal: ${JSON.stringify(text.slice(0, 30))}` : "terminal: none open";
}
if (roll < 0.98) {
return s.exec(
`const items = [...document.querySelectorAll(".sidebar-nav button, .m-tabbar button, nav button")].filter((b) => b.getBoundingClientRect().width);
if (!items.length) return "nav: none";
const b = items[Math.floor(arguments[0] * items.length)]; b.click(); return "nav: " + b.innerText.trim().split("\\n")[0]`,
random(),
);
}
const [w, h] = pick([
[390, 844],
[640, 800],
[860, 700],
[1024, 700],
[1280, 800],
[1600, 900],
]);
await s.resize(w, h);
return `resize: ${w}×${h}`;
}

async function fail(why) {
const report = [
`${label}: ${why}`,
`seed ${SEED}, step ${step} of ${STEPS} (MONKEY_SEED=${SEED} repeats it)`,
"last steps:",
...trail.slice(-25).map((t) => ` ${t}`),
].join("\n");
writeFileSync(`${out}/failure.txt`, `${report}\n`);
await s.screenshot(`${out}/failure.png`).catch(() => {});
throw new Error(report);
}

try {
await createVault(s, PASSWORD);
await s.resize(1280, 800);
const invoke = (cmd, args) => s.exec(`return window.__TAURI_INTERNALS__.invoke(arguments[0], arguments[1])`, cmd, args);
// Something on every page: a key, groups, hosts (where nothing listens), a snippet, a tunnel.
const key = await invoke("key_generate", { label: "monkey", vaultId: null });
const prod = (await invoke("group_save", { input: { name: "Production" } })).id;
await invoke("group_save", { input: { name: "Staging" } });
const auth = { kind: "key", keyId: key.id };
for (const [name, addr, groupId] of [
["web-1", "127.0.0.9", prod],
["web-2", "127.0.0.10", prod],
["db-1", "127.0.0.11", null],
]) {
await invoke("host_save", { input: { label: name, hostname: addr, port: 1, user: "deploy", auth, groupId, tags: ["monkey"] } });
}
await invoke("snippet_save", { input: { name: "Disk", body: "df -h", tags: ["ops"] } });
await invoke("plugin:event|emit", { event: "vault-changed", payload: null });
await sleep(500);
console.log(` ${label}: seed ${SEED}, ${STEPS} steps`);

for (step = 1; step <= STEPS; step++) {
await s.exec(PAGE_HOOKS);
let did;
try {
did = await act();
} catch (e) {
// The page went away under a script (a click that closed what it was in): note it, go on.
did = `(${String(e.message).slice(0, 80)})`;
}
trail.push(`${step}. ${did}`);
const kind = did.split(/[: ]/)[0];
counts[kind] = (counts[kind] ?? 0) + 1;
await sleep(between(40, 220));
let health;
try {
health = await s.exec(
`const errors = (window.__monkey || []).splice(0);
const root = document.getElementById("root");
return { errors, rendered: !!root && root.childElementCount > 0 }`,
);
} catch (e) {
await fail(`the app stopped answering: ${e.message}`);
}
if (health.errors.length) await fail(`the page threw: ${health.errors.join(" | ")}`);
if (!health.rendered) await fail("the page went blank (a render crashed)");
}
console.log(` ✓ ${label}: ${STEPS} random steps, nothing thrown (${Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ")}), seed ${SEED}`);
} finally {
await s.close();
}
21 changes: 20 additions & 1 deletion apps/desktop/e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ if want appimage; then
# launch (binfmt), the app's own relaunch after an update included. TMPDIR: where an AppImage
# unpacks to run, in the run's folder rather than /tmp, removed when the run ends.
mkdir -p "$run/appimage/tmp"
APPIMAGE_EXTRACT_AND_RUN=1 APPIMAGELAUNCHER_DISABLE=1 TMPDIR="$run/appimage/tmp" MORPHGATE_BROWSER="$here/browser.sh" \
APPIMAGE_EXTRACT_AND_RUN=1 APPIMAGELAUNCHER_DISABLE=1 TMPDIR="$run/appimage/tmp" MORPHGATE_BROWSER="$here/browser.sh" E2E_BROWSER_LOG="$run/appimage/browser.log" \
dbus-run-session -- "$here/session.sh" "$run/appimage" \
node "$here/appimage.mjs" "$run/appimage/$appimage" "$run/appimage/shots" "$run/feed" \
"$run/sshd/client_key" "$ssh_port" "$(id -un)" "$version" "$next" 2>"$run/appimage/stderr.log"
Expand Down Expand Up @@ -254,6 +254,25 @@ if want mobile; then
"$run/mobile/key" "$ssh_port" "$(id -un)" 2>"$run/mobile/stderr.log"
fi

# A monkey test of each package, only when asked for: E2E_ONLY=monkey (MONKEY_STEPS, MONKEY_SEED).
if [ -n "${E2E_ONLY:-}" ] && want monkey; then
for pkg in appimage deb rpm; do
echo "e2e: monkey ($pkg)"
dir="$run/monkey-$pkg"
mkdir -p "$dir/root" "$dir/tmp"
case $pkg in
appimage) cp "$artifacts/$appimage" "$dir/" && bin="$dir/$appimage" ;;
deb) (cd "$dir/root" && ar x "$artifacts/$deb" && tar -xzf data.tar.gz) && bin="$dir/root/usr/bin/morphgate" ;;
rpm) (cd "$dir/root" && rpm2cpio "$artifacts/$rpm" | cpio -idm --quiet) && bin="$dir/root/usr/bin/morphgate" ;;
esac
APPIMAGE_EXTRACT_AND_RUN=1 APPIMAGELAUNCHER_DISABLE=1 TMPDIR="$dir/tmp" MORPHGATE_BROWSER="$here/browser.sh" \
dbus-run-session -- "$here/session.sh" "$dir" node "$here/monkey.mjs" "$bin" "$dir" "$pkg" 2>"$dir/stderr.log"
# A panic in the app's Rust side counts too.
if grep -h "panicked at" "$dir"/*.log; then echo "e2e: monkey ($pkg): the app panicked" >&2; exit 1; fi
rm -rf "$dir/tmp"
done
fi

# The README's demo (assets/demo/morphgate.svg), only when asked for: E2E_ONLY=demo.
if [ -n "${E2E_ONLY:-}" ] && want demo; then
echo "e2e: demo (the AppImage's Hosts page and a session, drawn for the README)"
Expand Down
20 changes: 16 additions & 4 deletions apps/desktop/src/AboutPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { dateText, device, errorText, updates, type About, type UpdateInfo, type UpdateProgress } from "./api";
import { dateText, device, errorText, openLink, updates, type About, type UpdateInfo, type UpdateProgress } from "./api";
import { Licenses } from "./Licenses";
import { Logo } from "./Logo";
import { mobileOs, onIos } from "./mobile/useMobile";
Expand All @@ -25,6 +25,10 @@ const PACKAGE_NAMES: Record<NonNullable<About["package"]>, string> = {

const mib = (n: number) => `${(n / 1048576).toFixed(1)} MiB`;

/** Where people read about MorphGate, and where its code is. */
const DOCS = "https://morphgate.com/docs";
const SOURCE = "https://github.com/Vallic/MorphGate";

/** Version, and updates from MorphGate's releases. */
export function AboutPanel({ update, onUpdate, onAutoCheckChanged, onClose }: Props) {
const [about, setAbout] = useState<About | null>(null);
Expand Down Expand Up @@ -92,9 +96,17 @@ export function AboutPanel({ update, onUpdate, onAutoCheckChanged, onClose }: Pr
</div>
</div>
<p className="hint">SSH client with an end-to-end encrypted vault. Free software under the Mozilla Public License 2.0 · morphgate.com</p>
<button type="button" className="link align-start" onClick={() => setLicenses(true)}>
Open-source licenses
</button>
<div className="row wrap about-links">
<button type="button" className="link" onClick={() => void openLink(DOCS).catch((e) => setError(errorText(e)))}>
Documentation ↗
</button>
<button type="button" className="link" onClick={() => void openLink(SOURCE).catch((e) => setError(errorText(e)))}>
Source code on GitHub ↗
</button>
<button type="button" className="link" onClick={() => setLicenses(true)}>
Open-source licenses
</button>
</div>
<hr className="divider" />
<h2 className="section-title">Updates</h2>
{mobileOs ? (
Expand Down
Loading