Skip to content

Commit e9d03b2

Browse files
Recover missing release notes
Resolve What’s New data from the updater manifest or GitHub release when local updater state only has the version. Also preserve notes from raw updater metadata when the plugin body/date fields are empty.
1 parent 6a17462 commit e9d03b2

5 files changed

Lines changed: 206 additions & 21 deletions

File tree

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
}
3434
],
3535
"security": {
36-
"csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' ipc: http://ipc.localhost http://localhost:3000 http://127.0.0.1:3000 https://athas.dev https://*.athas.dev https://api.anthropic.com https://api.openai.com https://api.mistral.ai https://openrouter.ai https://generativelanguage.googleapis.com https://*.githubusercontent.com; img-src 'self' asset: http://asset.localhost https: data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval'",
36+
"csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' ipc: http://ipc.localhost http://localhost:3000 http://127.0.0.1:3000 https://athas.dev https://*.athas.dev https://api.github.com https://api.anthropic.com https://api.openai.com https://api.mistral.ai https://openrouter.ai https://generativelanguage.googleapis.com https://*.githubusercontent.com; img-src 'self' asset: http://asset.localhost https: data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval'",
3737
"capabilities": ["main-capability"],
3838
"assetProtocol": {
3939
"enable": true,

src/features/settings/hooks/use-updater.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ interface CheckForUpdatesOptions {
4040
ignoreSuppression?: boolean;
4141
}
4242

43+
function readRawUpdateText(update: Update, key: string): string | undefined {
44+
const value = update.rawJson?.[key];
45+
return typeof value === "string" && value.trim() ? value : undefined;
46+
}
47+
48+
function toUpdateInfo(update: Update): UpdateInfo {
49+
return {
50+
version: update.version,
51+
currentVersion: update.currentVersion,
52+
body: update.body || readRawUpdateText(update, "notes"),
53+
date: update.date || readRawUpdateText(update, "pub_date"),
54+
};
55+
}
56+
4357
export const useUpdater = (checkOnMount = true) => {
4458
const [state, setState] = useState<UpdateState>({
4559
available: false,
@@ -63,12 +77,7 @@ export const useUpdater = (checkOnMount = true) => {
6377
const currentVersion = update?.currentVersion ?? "";
6478

6579
if (update?.available) {
66-
const updateInfo = {
67-
version: update.version,
68-
currentVersion: update.currentVersion,
69-
body: update.body,
70-
date: update.date,
71-
};
80+
const updateInfo = toUpdateInfo(update);
7281

7382
clearUpdatePreferencesForNewVersion(updateInfo);
7483

@@ -145,12 +154,7 @@ export const useUpdater = (checkOnMount = true) => {
145154
throw new Error("No update available");
146155
}
147156
updateRef.current = newUpdate;
148-
updateInfoRef.current = {
149-
version: newUpdate.version,
150-
currentVersion: newUpdate.currentVersion,
151-
body: newUpdate.body,
152-
date: newUpdate.date,
153-
};
157+
updateInfoRef.current = toUpdateInfo(newUpdate);
154158
}
155159

156160
const canRestart = await prepareProjectTransitionWithUnsavedBuffers(
@@ -265,7 +269,7 @@ export const useUpdater = (checkOnMount = true) => {
265269
return;
266270
}
267271

268-
useWhatsNewStore.getState().openInfo({
272+
void useWhatsNewStore.getState().openInfo({
269273
version: updateInfo.version,
270274
previousVersion: updateInfo.currentVersion,
271275
body: updateInfo.body,

src/features/settings/lib/whats-new.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ export interface WhatsNewInfo {
55
date?: string;
66
}
77

8+
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
9+
10+
interface UpdateManifestResponse {
11+
version?: unknown;
12+
notes?: unknown;
13+
pub_date?: unknown;
14+
}
15+
16+
interface GitHubReleaseResponse {
17+
body?: unknown;
18+
published_at?: unknown;
19+
}
20+
821
interface WhatsNewStorageState {
922
current?: WhatsNewInfo;
1023
pending?: WhatsNewInfo;
@@ -41,6 +54,96 @@ export function buildWhatsNewMarkdown(info: WhatsNewInfo): string {
4154
return lines.join("\n");
4255
}
4356

57+
function releaseTag(version: string): string {
58+
return `v${version}`;
59+
}
60+
61+
function updateChannel(version: string): "stable" | "preview" {
62+
return version.includes("-preview.") ? "preview" : "stable";
63+
}
64+
65+
function readText(value: unknown): string | undefined {
66+
return typeof value === "string" && value.trim() ? value : undefined;
67+
}
68+
69+
function normalizeDate(value: string | undefined): string | undefined {
70+
if (!value) {
71+
return undefined;
72+
}
73+
74+
return value.slice(0, 10);
75+
}
76+
77+
async function readJson(response: Response): Promise<unknown> {
78+
if (!response.ok) {
79+
return null;
80+
}
81+
82+
try {
83+
return await response.json();
84+
} catch {
85+
return null;
86+
}
87+
}
88+
89+
async function fetchManifestInfo(info: WhatsNewInfo, fetchImpl: FetchLike): Promise<WhatsNewInfo> {
90+
const response = await fetchImpl(`https://athas.dev/api/update/${updateChannel(info.version)}`, {
91+
cache: "no-store",
92+
});
93+
const manifest = (await readJson(response)) as UpdateManifestResponse | null;
94+
95+
if (manifest?.version !== info.version) {
96+
return info;
97+
}
98+
99+
return {
100+
...info,
101+
body: info.body || readText(manifest.notes),
102+
date: info.date || normalizeDate(readText(manifest.pub_date)),
103+
};
104+
}
105+
106+
async function fetchGitHubReleaseInfo(
107+
info: WhatsNewInfo,
108+
fetchImpl: FetchLike,
109+
): Promise<WhatsNewInfo> {
110+
const response = await fetchImpl(
111+
`https://api.github.com/repos/athasdev/athas/releases/tags/${releaseTag(info.version)}`,
112+
{ cache: "no-store" },
113+
);
114+
const release = (await readJson(response)) as GitHubReleaseResponse | null;
115+
116+
return {
117+
...info,
118+
body: info.body || readText(release?.body),
119+
date: info.date || normalizeDate(readText(release?.published_at)),
120+
};
121+
}
122+
123+
export async function resolveWhatsNewInfo(
124+
info: WhatsNewInfo,
125+
fetchImpl: FetchLike = fetch,
126+
): Promise<WhatsNewInfo> {
127+
if (info.body?.trim()) {
128+
return info;
129+
}
130+
131+
try {
132+
const manifestInfo = await fetchManifestInfo(info, fetchImpl);
133+
if (manifestInfo.body?.trim()) {
134+
return manifestInfo;
135+
}
136+
} catch {
137+
// Keep the local fallback available when release metadata cannot be fetched.
138+
}
139+
140+
try {
141+
return await fetchGitHubReleaseInfo(info, fetchImpl);
142+
} catch {
143+
return info;
144+
}
145+
}
146+
44147
function readState(): WhatsNewStorageState {
45148
if (typeof window === "undefined") {
46149
return {};
@@ -79,6 +182,14 @@ export function queuePendingWhatsNew(info: WhatsNewInfo) {
79182
});
80183
}
81184

185+
export function storeCurrentWhatsNew(info: WhatsNewInfo) {
186+
const state = readState();
187+
writeState({
188+
...state,
189+
current: info,
190+
});
191+
}
192+
82193
export function hydrateWhatsNew(currentVersion: string): {
83194
info: WhatsNewInfo;
84195
shouldAutoOpen: boolean;

src/features/settings/stores/whats-new.store.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
buildWhatsNewMarkdown,
77
hydrateWhatsNew,
88
queuePendingWhatsNew,
9+
resolveWhatsNewInfo,
10+
storeCurrentWhatsNew,
911
type WhatsNewInfo,
1012
} from "../lib/whats-new";
1113

@@ -14,7 +16,7 @@ interface WhatsNewState {
1416
info: WhatsNewInfo | null;
1517
initialize: () => Promise<void>;
1618
open: () => Promise<void>;
17-
openInfo: (info: WhatsNewInfo) => void;
19+
openInfo: (info: WhatsNewInfo) => Promise<void>;
1820
queuePendingUpdate: (updateInfo: UpdateInfo) => void;
1921
}
2022

@@ -39,14 +41,16 @@ export const useWhatsNewStore = create<WhatsNewState>()((set, get) => ({
3941

4042
const currentVersion = await getVersion();
4143
const { info, shouldAutoOpen } = hydrateWhatsNew(currentVersion);
44+
const resolvedInfo = await resolveWhatsNewInfo(info);
45+
storeCurrentWhatsNew(resolvedInfo);
4246

4347
set({
4448
initialized: true,
45-
info,
49+
info: resolvedInfo,
4650
});
4751

4852
if (shouldAutoOpen) {
49-
openWhatsNewBuffer(info);
53+
openWhatsNewBuffer(resolvedInfo);
5054
}
5155
},
5256

@@ -60,11 +64,15 @@ export const useWhatsNewStore = create<WhatsNewState>()((set, get) => ({
6064
return;
6165
}
6266

63-
openWhatsNewBuffer(info);
67+
const resolvedInfo = await resolveWhatsNewInfo(info);
68+
storeCurrentWhatsNew(resolvedInfo);
69+
set({ info: resolvedInfo });
70+
openWhatsNewBuffer(resolvedInfo);
6471
},
6572

66-
openInfo: (info) => {
67-
openWhatsNewBuffer(info);
73+
openInfo: async (info) => {
74+
const resolvedInfo = await resolveWhatsNewInfo(info);
75+
openWhatsNewBuffer(resolvedInfo);
6876
},
6977

7078
queuePendingUpdate: (updateInfo) => {

src/features/settings/tests/whats-new.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vite-plus/test";
2-
import { buildWhatsNewMarkdown } from "../lib/whats-new";
2+
import { buildWhatsNewMarkdown, resolveWhatsNewInfo } from "../lib/whats-new";
33

44
describe("buildWhatsNewMarkdown", () => {
55
it("uses bundled release notes when available", () => {
@@ -19,3 +19,65 @@ describe("buildWhatsNewMarkdown", () => {
1919
expect(markdown).toContain("review the GitHub release page");
2020
});
2121
});
22+
23+
describe("resolveWhatsNewInfo", () => {
24+
it("hydrates missing release notes from the current updater manifest", async () => {
25+
const fetchCalls: string[] = [];
26+
const fetchReleaseNotes = async (url: string | URL | Request) => {
27+
fetchCalls.push(String(url));
28+
return Response.json({
29+
version: "1.2.0",
30+
notes: "Fixed release notes.",
31+
pub_date: "2026-07-08T12:34:56Z",
32+
});
33+
};
34+
35+
await expect(
36+
resolveWhatsNewInfo({ version: "1.2.0" }, fetchReleaseNotes),
37+
).resolves.toEqual({
38+
version: "1.2.0",
39+
body: "Fixed release notes.",
40+
date: "2026-07-08",
41+
});
42+
expect(fetchCalls).toEqual(["https://athas.dev/api/update/stable"]);
43+
});
44+
45+
it("falls back to the GitHub release when the updater manifest is for another version", async () => {
46+
const fetchCalls: string[] = [];
47+
const fetchReleaseNotes = async (url: string | URL | Request) => {
48+
const href = String(url);
49+
fetchCalls.push(href);
50+
51+
if (href.includes("athas.dev")) {
52+
return Response.json({ version: "1.3.0", notes: "Newer release." });
53+
}
54+
55+
return Response.json({
56+
body: "Archived release notes.",
57+
published_at: "2026-07-07T09:00:00Z",
58+
});
59+
};
60+
61+
await expect(
62+
resolveWhatsNewInfo({ version: "1.2.0" }, fetchReleaseNotes),
63+
).resolves.toEqual({
64+
version: "1.2.0",
65+
body: "Archived release notes.",
66+
date: "2026-07-07",
67+
});
68+
expect(fetchCalls).toEqual([
69+
"https://athas.dev/api/update/stable",
70+
"https://api.github.com/repos/athasdev/athas/releases/tags/v1.2.0",
71+
]);
72+
});
73+
74+
it("keeps the local fallback when release metadata cannot be fetched", async () => {
75+
const fetchReleaseNotes = async () => {
76+
throw new Error("offline");
77+
};
78+
79+
await expect(
80+
resolveWhatsNewInfo({ version: "1.2.0" }, fetchReleaseNotes),
81+
).resolves.toEqual({ version: "1.2.0" });
82+
});
83+
});

0 commit comments

Comments
 (0)