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
7 changes: 6 additions & 1 deletion apps/app/src/features/connections/connection-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ export function ConnectionSwitcher(): ReactElement | null {
{environments.remotes.length > 0 || environments.connecting.length > 0 ? (
<MenuSeparator />
) : null}
{launch.available ? (
{environments.hostsError === undefined ? null : (
<MenuItem disabled title={environments.hostsError}>
<span>{environments.hostsError}</span>
</MenuItem>
)}
{environments.hostsError !== undefined ? null : launch.available ? (
<MenuItem
disabled={blocking}
onClick={() => {
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type EnvironmentSnapshot = {
readonly revision: number;
readonly connecting: readonly ConnectingSshHost[];
readonly remotes: readonly SshRemoteEnvironment[];
readonly hostsError?: string;
};

export type DiscoveredSshHost = {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export default defineConfig({
exclude: [
"@getpie/contract",
"@getpie/core",
"@getpie/effect-json-store",
"@getpie/server",
"@getpie/ssh",
"@getpie/tailscale",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@getpie/cli": "workspace:*",
"@getpie/contract": "workspace:*",
"@getpie/core": "workspace:*",
"@getpie/effect-json-store": "workspace:*",
"@getpie/server": "workspace:*",
"@getpie/ssh": "workspace:*",
"@getpie/tailscale": "workspace:*",
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/src/main/application/desktop-application.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Deferred, Effect, Option, Scope, Stream, SubscriptionRef } from "effect";
import { Deferred, Effect, Option, Schema, Scope, Stream, SubscriptionRef } from "effect";
import { describe, expect, it } from "vitest";

import type { ServerConnection, ServerStatusSnapshot } from "../../shared/desktop-rpc";
import {
EnvironmentSnapshotSchema,
type ServerConnection,
type ServerStatusSnapshot,
} from "../../shared/desktop-rpc";
import { disabledDesktopCli } from "../cli/desktop-cli";
import type { LocalServer } from "../server/local-server";
import { disabledDesktopSsh, SshHostDiscoveryError } from "../ssh/desktop-ssh";
Expand Down Expand Up @@ -165,7 +169,8 @@ describe("DesktopApplication", () => {
await expect(Effect.runPromise(h.application.serverConnection)).resolves.toEqual(
localConnection,
);
await expect(Effect.runPromise(h.application.environmentSnapshot)).resolves.toMatchObject({
const snapshot = await Effect.runPromise(h.application.environmentSnapshot);
expect(snapshot).toMatchObject({
connecting: [],
remotes: [
{
Expand All @@ -175,6 +180,7 @@ describe("DesktopApplication", () => {
},
],
});
expect(Schema.encodeSync(EnvironmentSnapshotSchema)(snapshot)).not.toHaveProperty("hostsError");
});

it("reports a missing OpenSSH client on bootstrap", async () => {
Expand Down
24 changes: 16 additions & 8 deletions apps/desktop/src/main/application/desktop-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,14 @@ export type DesktopApplicationDependencies = {
readonly scope: Scope.Scope;
};

function emptySnapshot(): EnvironmentSnapshot {
return {
function emptySnapshot(hostsError?: string): EnvironmentSnapshot {
const snapshot = {
revision: 0,
connecting: [],
remotes: [],
};
if (hostsError === undefined) return snapshot;
return { ...snapshot, hostsError };
}

export function makeDesktopApplication({
Expand All @@ -92,29 +94,35 @@ export function makeDesktopApplication({
scope,
}: DesktopApplicationDependencies): DesktopApplication["Service"] {
const environmentsRef = Effect.runSync(
SubscriptionRef.make<EnvironmentSnapshot>(emptySnapshot()),
SubscriptionRef.make<EnvironmentSnapshot>(emptySnapshot(ssh.savedHostsMessage)),
);
const visible = Effect.runSync(SubscriptionRef.make(false));

const updateEnvironments = (
updater: (current: EnvironmentSnapshot) => Omit<EnvironmentSnapshot, "revision">,
): Effect.Effect<EnvironmentSnapshot> =>
SubscriptionRef.updateAndGet(environmentsRef, (current) => ({
...updater(current),
revision: current.revision + 1,
}));
SubscriptionRef.updateAndGet(environmentsRef, (current) => {
const next = {
...updater(current),
revision: current.revision + 1,
};
if (current.hostsError === undefined) return next;
return { ...next, hostsError: current.hostsError };
});

const dropRemoteIfCurrent = (remote: SshRemoteEnvironment) =>
SubscriptionRef.updateAndGet(environmentsRef, (current) => {
const existing = current.remotes.find((entry) => entry.id === remote.id);
if (existing === undefined || existing.connection !== remote.connection) {
return current;
}
return {
const next = {
connecting: current.connecting,
remotes: current.remotes.filter((entry) => entry.id !== remote.id),
revision: current.revision + 1,
};
if (current.hostsError === undefined) return next;
return { ...next, hostsError: current.hostsError };
});

const connectSsh = (target: string, options?: { readonly background?: boolean }) =>
Expand Down
185 changes: 113 additions & 72 deletions apps/desktop/src/main/ssh/desktop-ssh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Effect, FileSystem, Layer, Scope } from "effect";
import { ChildProcessSpawner } from "effect/process";
import { describe, expect, it } from "vitest";

import { makeDesktopSsh } from "./desktop-ssh";
import { makeDesktopSsh, SAVED_HOSTS_UNAVAILABLE } from "./desktop-ssh";

function fakeConnected(target: SshTarget): SshConnectedEnvironment {
return {
Expand Down Expand Up @@ -57,94 +57,81 @@ describe("DesktopSsh saved hosts", () => {
it("returns no hosts when the persist file is missing", async () => {
const saved = await withSsh((dir) =>
Effect.gen(function* () {
const ssh = yield* makeDesktopSsh({ persistPath: path.join(dir, "ssh-environments.json") });
return yield* ssh.listSaved;
const file = path.join(dir, "ssh-environments.json");
const ssh = yield* makeDesktopSsh({ persistPath: file });
const fs = yield* FileSystem.FileSystem;
return { saved: yield* ssh.listSaved, exists: yield* fs.exists(file) };
}),
);
expect(saved).toEqual([]);
expect(saved.saved).toEqual([]);
expect(saved.exists).toBe(false);
});

it("reads version-1 hosts and skips malformed entries", async () => {
const saved = await withSsh((dir) =>
it("starts when a version-1 file is invalid and leaves it untouched", async () => {
const fileText = JSON.stringify({
version: 1,
data: {
environments: [
{
id: "abc123abc123abcd",
alias: "myserver",
hostname: "example.com",
username: "alice",
port: 2222,
},
{ id: "", alias: "bad" },
],
},
});
const error = await withSsh((dir) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString(
path.join(dir, "ssh-environments.json"),
JSON.stringify({
version: 1,
data: {
environments: [
{
id: "abc123abc123abcd",
alias: "myserver",
hostname: "example.com",
username: "alice",
port: 2222,
},
{ id: "", alias: "bad" },
{
id: "deadbeefdeadbeef",
alias: "other",
hostname: "other.example",
username: null,
port: null,
},
],
},
}),
);
const ssh = yield* makeDesktopSsh({ persistPath: path.join(dir, "ssh-environments.json") });
return yield* ssh.listSaved;
const file = path.join(dir, "ssh-environments.json");
yield* fs.writeFileString(file, fileText);
const ssh = yield* makeDesktopSsh({ persistPath: file });
const listed = yield* ssh.listSaved;
const connect = yield* ssh.connect("alice@example.com").pipe(Effect.flip);
return {
listed,
connect,
raw: yield* fs.readFileString(file),
message: ssh.savedHostsMessage,
};
}),
);

expect(saved).toEqual([
{
id: "abc123abc123abcd",
target: {
expect(error.listed).toEqual([]);
expect(error.connect.message).toBe(SAVED_HOSTS_UNAVAILABLE);
expect(error.message).toBe(SAVED_HOSTS_UNAVAILABLE);
expect(error.raw).toBe(fileText);
});

it("adopts a pre-envelope file without dropping valid hosts", async () => {
const fileText = JSON.stringify({
version: 1,
activeId: "abc123abc123abcd",
environments: [
{
id: "abc123abc123abcd",
alias: "myserver",
hostname: "example.com",
username: "alice",
port: 2222,
},
},
{
id: "deadbeefdeadbeef",
target: {
alias: "other",
hostname: "other.example",
username: null,
port: null,
port: 22,
},
},
]);
});

it("still reads a pre-envelope version-1 file", async () => {
const saved = await withSsh((dir) =>
],
});
const error = await withSsh((dir) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString(
path.join(dir, "ssh-environments.json"),
JSON.stringify({
version: 1,
activeId: "abc123abc123abcd",
environments: [
{
id: "abc123abc123abcd",
alias: "myserver",
hostname: "example.com",
username: "alice",
port: 22,
},
],
}),
);
const ssh = yield* makeDesktopSsh({ persistPath: path.join(dir, "ssh-environments.json") });
return yield* ssh.listSaved;
const file = path.join(dir, "ssh-environments.json");
yield* fs.writeFileString(file, fileText);
const ssh = yield* makeDesktopSsh({ persistPath: file });
const listed = yield* ssh.listSaved;
const info = yield* fs.stat(file);
return { listed, raw: yield* fs.readFileString(file), mode: (info.mode ?? 0) & 0o777 };
}),
);
expect(saved).toEqual([
expect(error.listed).toEqual([
{
id: "abc123abc123abcd",
target: {
Expand All @@ -155,6 +142,60 @@ describe("DesktopSsh saved hosts", () => {
},
},
]);
const parsed: unknown = JSON.parse(error.raw);
expect(parsed).toEqual({
version: 1,
data: {
environments: [
{
id: "abc123abc123abcd",
alias: "myserver",
hostname: "example.com",
username: "alice",
port: 22,
},
],
},
});
expect(error.mode).toBe(0o600);
});

it("does not adopt a pre-envelope file when any entry is invalid", async () => {
const fileText = JSON.stringify({
version: 1,
activeId: "abc123abc123abcd",
environments: [
{
id: "abc123abc123abcd",
alias: "myserver",
hostname: "example.com",
username: "alice",
port: 22,
},
{ id: "", alias: "bad" },
],
});
const result = await withSsh((dir) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const file = path.join(dir, "ssh-environments.json");
yield* fs.writeFileString(file, fileText);
const ssh = yield* makeDesktopSsh({ persistPath: file });
const listed = yield* ssh.listSaved;
const connect = yield* ssh.connect("alice@example.com").pipe(Effect.flip);
return {
listed,
connect,
raw: yield* fs.readFileString(file),
message: ssh.savedHostsMessage,
};
}),
);

expect(result.listed).toEqual([]);
expect(result.connect.message).toBe(SAVED_HOSTS_UNAVAILABLE);
expect(result.message).toBe(SAVED_HOSTS_UNAVAILABLE);
expect(result.raw).toBe(fileText);
});

it("remove deletes a saved host from disk", async () => {
Expand Down
Loading
Loading