Skip to content
Open
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
51 changes: 51 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, describe, it } from "@effect/vitest";
import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
Expand All @@ -23,6 +24,56 @@ import * as DesktopUpdates from "./DesktopUpdates.ts";
import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts";

describe("DesktopUpdates", () => {
it.effect("retains a completed download when an overlapping availability callback resumes", () =>
Effect.gen(function* () {
const timestampRequested = yield* Deferred.make<void>();
const releaseTimestamp = yield* Deferred.make<void>();
const clock = yield* Clock.Clock;
let pauseNextTimestamp = false;
const callbackClock: Clock.Clock = {
...clock,
currentTimeMillis: Effect.suspend(() => {
if (!pauseNextTimestamp) return clock.currentTimeMillis;
pauseNextTimestamp = false;
return Deferred.succeed(timestampRequested, undefined).pipe(
Effect.andThen(Deferred.await(releaseTimestamp)),
Effect.andThen(clock.currentTimeMillis),
);
}),
};
const harness = makeHarness();

yield* Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure.pipe(Effect.provideService(Clock.Clock, callbackClock));
const { changes } = yield* updates.subscribe;

pauseNextTimestamp = true;
harness.emit("update-available", { version: "1.2.4" });
yield* Deferred.await(timestampRequested);
harness.emit("update-downloaded", { version: "1.2.4" });
const downloaded = Option.getOrThrow(yield* Stream.runHead(changes));
assert.equal(downloaded.status, "downloaded");

const { changes: resumedChanges } = yield* updates.subscribe;
yield* Deferred.succeed(releaseTimestamp, undefined);
const resumed = Option.getOrThrow(yield* Stream.runHead(resumedChanges));
assert.equal(resumed.status, "downloaded");
assert.equal(resumed.downloadedVersion, "1.2.4");
assert.equal(resumed.downloadPercent, 100);

const { changes: progressChanges } = yield* updates.subscribe;
harness.emit("download-progress", { percent: 80 });
const afterProgress = Option.getOrThrow(yield* Stream.runHead(progressChanges));
assert.equal(afterProgress.status, "downloaded");
assert.equal(afterProgress.downloadedVersion, "1.2.4");
assert.equal(afterProgress.downloadPercent, 100);
}),
).pipe(Effect.provide(harness.layer));
}),
);

it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () =>
Effect.gen(function* () {
const linuxState = (packageType: string | undefined) =>
Expand Down
140 changes: 76 additions & 64 deletions apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,22 +414,26 @@ export const make = Effect.gen(function* () {
Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)),
);

const setState = (state: DesktopUpdateState): Effect.Effect<void> =>
stateMutex
.withPermits(1)(
Ref.set(updateStateRef, state).pipe(Effect.andThen(PubSub.publish(stateChanges, state))),
)
.pipe(Effect.andThen(emitState));

const updateState = (
f: (state: DesktopUpdateState) => DesktopUpdateState,
): Effect.Effect<DesktopUpdateState> =>
Ref.get(updateStateRef).pipe(
Effect.flatMap((state) => {
const nextState = f(state);
return setState(nextState).pipe(Effect.as(nextState));
const updateStateEffect = Effect.fn("desktop.updates.updateState")(function* (
f: (state: DesktopUpdateState) => Effect.Effect<DesktopUpdateState>,
) {
const state = yield* stateMutex.withPermits(1)(
Effect.gen(function* () {
const current = yield* Ref.get(updateStateRef);
const next = yield* f(current);
yield* Ref.set(updateStateRef, next);
yield* PubSub.publish(stateChanges, next);
return next;
}),
);
yield* emitState;
return state;
});

const updateState = (f: (state: DesktopUpdateState) => DesktopUpdateState) =>
updateStateEffect((state) => Effect.sync(() => f(state)));

const setState = (state: DesktopUpdateState) => updateState(() => state).pipe(Effect.asVoid);

const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe(
Effect.option,
Expand Down Expand Up @@ -925,40 +929,39 @@ export const make = Effect.gen(function* () {
yield* decodeUpdateInfo(raw).pipe(
Effect.flatMap(
Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) {
const state = yield* Ref.get(updateStateRef);
if (!(yield* offerable(info.version, state))) {
yield* logUpdaterInfo("ignoring update outside the selected channel or its floor", {
version: info.version,
channel: state.channel,
});
const checkedAt = yield* currentIsoTimestamp;
yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
return;
}

const checkedAt = yield* currentIsoTimestamp;
const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes(
info.releaseNotes,
info.version,
state.channel,
info.releaseDate,
);
yield* setState(
reduceDesktopUpdateStateOnUpdateAvailable(
state,
info.version,
checkedAt,
releaseNotes,
omittedReleaseCount,
),
const state = yield* updateStateEffect((current) =>
Effect.gen(function* () {
if (!(yield* offerable(info.version, current))) {
yield* logUpdaterInfo("ignoring update outside the selected channel or its floor", {
version: info.version,
channel: current.channel,
});
return reduceDesktopUpdateStateOnNoUpdate(current, checkedAt);
}
const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes(
info.releaseNotes,
info.version,
current.channel,
info.releaseDate,
);
return reduceDesktopUpdateStateOnUpdateAvailable(
current,
info.version,
checkedAt,
releaseNotes,
omittedReleaseCount,
);
}),
);
yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
yield* logUpdaterInfo("update available", {
version: info.version,
releaseNoteGroups: releaseNotes.length,
omittedReleaseCount,
});
if (state.availableVersion === info.version) {
yield* logUpdaterInfo("update available", {
version: info.version,
releaseNoteGroups: state.releaseNotes.length,
omittedReleaseCount: state.omittedReleaseCount,
});
}
}),
),
Effect.catchCause((cause) => {
Expand All @@ -977,8 +980,7 @@ export const make = Effect.gen(function* () {

const handleUpdateNotAvailable = Effect.gen(function* () {
const checkedAt = yield* currentIsoTimestamp;
const state = yield* Ref.get(updateStateRef);
yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
yield* updateState((state) => reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
yield* logUpdaterInfo("no updates available");
}).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable"));
Expand Down Expand Up @@ -1033,7 +1035,11 @@ export const make = Effect.gen(function* () {
const state = yield* Ref.get(updateStateRef);
const percent = Math.floor(progress.percent);
if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) {
yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent));
yield* updateState((current) =>
current.status === "downloaded"
? current
: reduceDesktopUpdateStateOnDownloadProgress(current, progress.percent),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
const milestone = percent - (percent % 10);
const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef);
Expand Down Expand Up @@ -1063,23 +1069,29 @@ export const make = Effect.gen(function* () {
yield* decodeUpdateInfo(raw).pipe(
Effect.flatMap(
Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) {
const state = yield* Ref.get(updateStateRef);
// A download can finish after its track switch moved on; never offer that build.
if (!(yield* offerable(info.version, state))) {
yield* logUpdaterInfo("ignoring download outside the selected channel or its floor", {
version: info.version,
channel: state.channel,
});
// Its progress events left the updater "downloading", which would block every check.
if (state.status === "downloading") {
yield* setState(
reduceDesktopUpdateStateOnNoUpdate(state, yield* currentIsoTimestamp),
);
}
return;
const checkedAt = yield* currentIsoTimestamp;
const state = yield* updateStateEffect((current) =>
Effect.gen(function* () {
// A download can finish after its track switch moved on; never offer that build.
if (!(yield* offerable(info.version, current))) {
yield* logUpdaterInfo(
"ignoring download outside the selected channel or its floor",
{
version: info.version,
channel: current.channel,
},
);
// Its progress events must not leave the updater stuck downloading.
return current.status === "downloading"
? reduceDesktopUpdateStateOnNoUpdate(current, checkedAt)
: current;
}
return reduceDesktopUpdateStateOnDownloadComplete(current, info.version);
}),
);
if (state.downloadedVersion === info.version) {
yield* logUpdaterInfo("update downloaded", { version: info.version });
}
yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version));
yield* logUpdaterInfo("update downloaded", { version: info.version });
}),
),
Effect.catchCause((cause) => {
Expand Down
Loading