@muhammedaksam/easiarr 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/portainer-api.ts +111 -0
- package/src/apps/registry.ts +57 -5
- package/src/compose/generator.ts +2 -1
- package/src/config/homepage-config.ts +247 -0
- package/src/config/manager.ts +31 -1
- package/src/config/schema.ts +13 -0
- package/src/docker/client.ts +135 -0
- package/src/ui/App.ts +15 -1
- package/src/ui/components/UpdateNotification.ts +101 -0
- package/src/ui/screens/ContainerControl.ts +509 -75
- package/src/ui/screens/HomepageSetup.ts +306 -0
- package/src/ui/screens/MainMenu.ts +17 -1
- package/src/ui/screens/QuickSetup.ts +2 -0
- package/src/utils/env.ts +23 -0
- package/src/utils/update-checker.ts +210 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Homepage Setup Screen
|
|
3
|
+
* Configure Homepage dashboard with enabled apps
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
|
|
7
|
+
import { createPageLayout } from "../components/PageLayout"
|
|
8
|
+
import { EasiarrConfig } from "../../config/schema"
|
|
9
|
+
import { saveHomepageConfig, generateServicesYaml } from "../../config/homepage-config"
|
|
10
|
+
import { getApp } from "../../apps/registry"
|
|
11
|
+
|
|
12
|
+
interface SetupResult {
|
|
13
|
+
name: string
|
|
14
|
+
status: "pending" | "configuring" | "success" | "error" | "skipped"
|
|
15
|
+
message?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Step = "menu" | "generating" | "preview" | "done"
|
|
19
|
+
|
|
20
|
+
export class HomepageSetup extends BoxRenderable {
|
|
21
|
+
private config: EasiarrConfig
|
|
22
|
+
private cliRenderer: CliRenderer
|
|
23
|
+
private onBack: () => void
|
|
24
|
+
private keyHandler!: (key: KeyEvent) => void
|
|
25
|
+
private results: SetupResult[] = []
|
|
26
|
+
private currentStep: Step = "menu"
|
|
27
|
+
private contentBox!: BoxRenderable
|
|
28
|
+
private pageContainer!: BoxRenderable
|
|
29
|
+
private menuIndex = 0
|
|
30
|
+
private previewContent = ""
|
|
31
|
+
|
|
32
|
+
constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
|
|
33
|
+
const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
|
|
34
|
+
title: "Homepage Setup",
|
|
35
|
+
stepInfo: "Configure Dashboard",
|
|
36
|
+
footerHint: [
|
|
37
|
+
{ type: "key", key: "↑↓", value: "Navigate" },
|
|
38
|
+
{ type: "key", key: "Enter", value: "Select" },
|
|
39
|
+
{ type: "key", key: "Esc", value: "Back" },
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
super(cliRenderer, { width: "100%", height: "100%" })
|
|
43
|
+
this.add(pageContainer)
|
|
44
|
+
|
|
45
|
+
this.config = config
|
|
46
|
+
this.cliRenderer = cliRenderer
|
|
47
|
+
this.onBack = onBack
|
|
48
|
+
this.contentBox = contentBox
|
|
49
|
+
this.pageContainer = pageContainer
|
|
50
|
+
|
|
51
|
+
this.initKeyHandler()
|
|
52
|
+
this.refreshContent()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private initKeyHandler(): void {
|
|
56
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
57
|
+
if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
|
|
58
|
+
if (this.currentStep === "menu") {
|
|
59
|
+
this.cleanup()
|
|
60
|
+
} else {
|
|
61
|
+
this.currentStep = "menu"
|
|
62
|
+
this.refreshContent()
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (this.currentStep === "menu") {
|
|
68
|
+
this.handleMenuKeys(key)
|
|
69
|
+
} else if (this.currentStep === "preview" || this.currentStep === "done") {
|
|
70
|
+
if (key.name === "return" || key.name === "escape") {
|
|
71
|
+
this.currentStep = "menu"
|
|
72
|
+
this.refreshContent()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private handleMenuKeys(key: KeyEvent): void {
|
|
80
|
+
const menuItems = this.getMenuItems()
|
|
81
|
+
|
|
82
|
+
if (key.name === "up" && this.menuIndex > 0) {
|
|
83
|
+
this.menuIndex--
|
|
84
|
+
this.refreshContent()
|
|
85
|
+
} else if (key.name === "down" && this.menuIndex < menuItems.length - 1) {
|
|
86
|
+
this.menuIndex++
|
|
87
|
+
this.refreshContent()
|
|
88
|
+
} else if (key.name === "return") {
|
|
89
|
+
this.executeMenuItem(this.menuIndex)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private getMenuItems(): { name: string; description: string; action: () => void }[] {
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
name: "📊 Generate Services",
|
|
97
|
+
description: "Create services.yaml with all enabled apps",
|
|
98
|
+
action: () => this.generateServices(),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "👁️ Preview Config",
|
|
102
|
+
description: "Preview generated services.yaml",
|
|
103
|
+
action: () => this.previewServices(),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "📋 Show Enabled Apps",
|
|
107
|
+
description: "List apps that will be added to Homepage",
|
|
108
|
+
action: () => this.showEnabledApps(),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "↩️ Back",
|
|
112
|
+
description: "Return to main menu",
|
|
113
|
+
action: () => this.cleanup(),
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private executeMenuItem(index: number): void {
|
|
119
|
+
const items = this.getMenuItems()
|
|
120
|
+
if (index >= 0 && index < items.length) {
|
|
121
|
+
items[index].action()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async generateServices(): Promise<void> {
|
|
126
|
+
this.currentStep = "generating"
|
|
127
|
+
this.results = [{ name: "services.yaml", status: "configuring" }]
|
|
128
|
+
this.refreshContent()
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const paths = await saveHomepageConfig(this.config)
|
|
132
|
+
this.results[0].status = "success"
|
|
133
|
+
this.results[0].message = `Saved to ${paths.services}`
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.results[0].status = "error"
|
|
136
|
+
this.results[0].message = error instanceof Error ? error.message : String(error)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.currentStep = "done"
|
|
140
|
+
this.refreshContent()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async previewServices(): Promise<void> {
|
|
144
|
+
this.previewContent = await generateServicesYaml(this.config)
|
|
145
|
+
this.currentStep = "preview"
|
|
146
|
+
this.refreshContent()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private showEnabledApps(): void {
|
|
150
|
+
const apps = this.config.apps.filter((a) => a.enabled && a.id !== "homepage")
|
|
151
|
+
|
|
152
|
+
this.results = apps.map((app) => {
|
|
153
|
+
const def = getApp(app.id)
|
|
154
|
+
const hasWidget = def?.homepage?.widget ? "📊" : "📌"
|
|
155
|
+
return {
|
|
156
|
+
name: `${hasWidget} ${def?.name || app.id}`,
|
|
157
|
+
status: "success" as const,
|
|
158
|
+
message: def?.description,
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if (this.results.length === 0) {
|
|
163
|
+
this.results = [{ name: "No apps enabled", status: "skipped", message: "Enable apps first" }]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.currentStep = "done"
|
|
167
|
+
this.refreshContent()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private refreshContent(): void {
|
|
171
|
+
this.contentBox.getChildren().forEach((child) => child.destroy())
|
|
172
|
+
|
|
173
|
+
if (this.currentStep === "menu") {
|
|
174
|
+
this.renderMenu()
|
|
175
|
+
} else if (this.currentStep === "preview") {
|
|
176
|
+
this.renderPreview()
|
|
177
|
+
} else {
|
|
178
|
+
this.renderResults()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private renderMenu(): void {
|
|
183
|
+
this.contentBox.add(
|
|
184
|
+
new TextRenderable(this.cliRenderer, {
|
|
185
|
+
content: "Configure Homepage dashboard with your enabled apps:\n\n",
|
|
186
|
+
fg: "#aaaaaa",
|
|
187
|
+
})
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
this.getMenuItems().forEach((item, idx) => {
|
|
191
|
+
const pointer = idx === this.menuIndex ? "→ " : " "
|
|
192
|
+
const fg = idx === this.menuIndex ? "#50fa7b" : "#8be9fd"
|
|
193
|
+
|
|
194
|
+
this.contentBox.add(
|
|
195
|
+
new TextRenderable(this.cliRenderer, {
|
|
196
|
+
content: `${pointer}${item.name}\n`,
|
|
197
|
+
fg,
|
|
198
|
+
})
|
|
199
|
+
)
|
|
200
|
+
this.contentBox.add(
|
|
201
|
+
new TextRenderable(this.cliRenderer, {
|
|
202
|
+
content: ` ${item.description}\n\n`,
|
|
203
|
+
fg: "#6272a4",
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private renderPreview(): void {
|
|
210
|
+
this.contentBox.add(
|
|
211
|
+
new TextRenderable(this.cliRenderer, {
|
|
212
|
+
content: "Preview: services.yaml\n",
|
|
213
|
+
fg: "#50fa7b",
|
|
214
|
+
})
|
|
215
|
+
)
|
|
216
|
+
this.contentBox.add(
|
|
217
|
+
new TextRenderable(this.cliRenderer, {
|
|
218
|
+
content: "─".repeat(40) + "\n",
|
|
219
|
+
fg: "#555555",
|
|
220
|
+
})
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
// Show preview (truncated)
|
|
224
|
+
const lines = this.previewContent.split("\n").slice(0, 30)
|
|
225
|
+
for (const line of lines) {
|
|
226
|
+
this.contentBox.add(
|
|
227
|
+
new TextRenderable(this.cliRenderer, {
|
|
228
|
+
content: line + "\n",
|
|
229
|
+
fg: line.startsWith("#") ? "#6272a4" : line.endsWith(":") ? "#8be9fd" : "#f8f8f2",
|
|
230
|
+
})
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (this.previewContent.split("\n").length > 30) {
|
|
235
|
+
this.contentBox.add(
|
|
236
|
+
new TextRenderable(this.cliRenderer, {
|
|
237
|
+
content: "\n... (truncated)\n",
|
|
238
|
+
fg: "#6272a4",
|
|
239
|
+
})
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
this.contentBox.add(
|
|
244
|
+
new TextRenderable(this.cliRenderer, {
|
|
245
|
+
content: "\nPress Enter or Esc to go back",
|
|
246
|
+
fg: "#6272a4",
|
|
247
|
+
})
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private renderResults(): void {
|
|
252
|
+
const headerText = this.currentStep === "done" ? "Results:\n\n" : "Generating...\n\n"
|
|
253
|
+
this.contentBox.add(
|
|
254
|
+
new TextRenderable(this.cliRenderer, {
|
|
255
|
+
content: headerText,
|
|
256
|
+
fg: this.currentStep === "done" ? "#50fa7b" : "#f1fa8c",
|
|
257
|
+
})
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
for (const result of this.results) {
|
|
261
|
+
let status = ""
|
|
262
|
+
let fg = "#aaaaaa"
|
|
263
|
+
switch (result.status) {
|
|
264
|
+
case "pending":
|
|
265
|
+
status = "⏳"
|
|
266
|
+
break
|
|
267
|
+
case "configuring":
|
|
268
|
+
status = "🔄"
|
|
269
|
+
fg = "#f1fa8c"
|
|
270
|
+
break
|
|
271
|
+
case "success":
|
|
272
|
+
status = "✓"
|
|
273
|
+
fg = "#50fa7b"
|
|
274
|
+
break
|
|
275
|
+
case "error":
|
|
276
|
+
status = "✗"
|
|
277
|
+
fg = "#ff5555"
|
|
278
|
+
break
|
|
279
|
+
case "skipped":
|
|
280
|
+
status = "⊘"
|
|
281
|
+
fg = "#6272a4"
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let content = `${status} ${result.name}`
|
|
286
|
+
if (result.message) {
|
|
287
|
+
content += ` - ${result.message}`
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this.contentBox.add(new TextRenderable(this.cliRenderer, { content: content + "\n", fg }))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.contentBox.add(
|
|
294
|
+
new TextRenderable(this.cliRenderer, {
|
|
295
|
+
content: "\nPress Enter or Esc to continue...",
|
|
296
|
+
fg: "#6272a4",
|
|
297
|
+
})
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private cleanup(): void {
|
|
302
|
+
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
303
|
+
this.destroy()
|
|
304
|
+
this.onBack()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -16,6 +16,7 @@ import { ProwlarrSetup } from "./ProwlarrSetup"
|
|
|
16
16
|
import { QBittorrentSetup } from "./QBittorrentSetup"
|
|
17
17
|
import { FullAutoSetup } from "./FullAutoSetup"
|
|
18
18
|
import { MonitorDashboard } from "./MonitorDashboard"
|
|
19
|
+
import { HomepageSetup } from "./HomepageSetup"
|
|
19
20
|
|
|
20
21
|
export class MainMenu {
|
|
21
22
|
private renderer: RenderContext
|
|
@@ -129,6 +130,10 @@ export class MainMenu {
|
|
|
129
130
|
name: "📊 Monitor Dashboard",
|
|
130
131
|
description: "Configure app health monitoring",
|
|
131
132
|
},
|
|
133
|
+
{
|
|
134
|
+
name: "🏠 Homepage Setup",
|
|
135
|
+
description: "Generate Homepage dashboard config",
|
|
136
|
+
},
|
|
132
137
|
{ name: "❌ Exit", description: "Close easiarr" },
|
|
133
138
|
],
|
|
134
139
|
})
|
|
@@ -227,7 +232,18 @@ export class MainMenu {
|
|
|
227
232
|
this.container.add(monitor)
|
|
228
233
|
break
|
|
229
234
|
}
|
|
230
|
-
case 11:
|
|
235
|
+
case 11: {
|
|
236
|
+
// Homepage Setup
|
|
237
|
+
this.menu.blur()
|
|
238
|
+
this.page.visible = false
|
|
239
|
+
const homepageSetup = new HomepageSetup(this.renderer as CliRenderer, this.config, () => {
|
|
240
|
+
this.page.visible = true
|
|
241
|
+
this.menu.focus()
|
|
242
|
+
})
|
|
243
|
+
this.container.add(homepageSetup)
|
|
244
|
+
break
|
|
245
|
+
}
|
|
246
|
+
case 12:
|
|
231
247
|
process.exit(0)
|
|
232
248
|
break
|
|
233
249
|
}
|
package/src/utils/env.ts
CHANGED
|
@@ -5,8 +5,31 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs"
|
|
7
7
|
import { writeFile, readFile } from "node:fs/promises"
|
|
8
|
+
import { networkInterfaces } from "node:os"
|
|
8
9
|
import { getComposePath } from "../config/manager"
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Get the local IP address of the Docker host
|
|
13
|
+
* Returns the first non-internal IPv4 address found
|
|
14
|
+
*/
|
|
15
|
+
export function getLocalIp(): string {
|
|
16
|
+
const nets = networkInterfaces()
|
|
17
|
+
|
|
18
|
+
for (const name of Object.keys(nets)) {
|
|
19
|
+
const interfaces = nets[name]
|
|
20
|
+
if (!interfaces) continue
|
|
21
|
+
|
|
22
|
+
for (const net of interfaces) {
|
|
23
|
+
// Skip internal (loopback) and non-IPv4 addresses
|
|
24
|
+
if (net.family === "IPv4" && !net.internal) {
|
|
25
|
+
return net.address
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return "localhost"
|
|
31
|
+
}
|
|
32
|
+
|
|
10
33
|
/**
|
|
11
34
|
* Get the path to the .env file
|
|
12
35
|
*/
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update Checker
|
|
3
|
+
* Check GitHub releases for new easiarr versions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { VersionInfo } from "../VersionInfo"
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import { homedir } from "node:os"
|
|
10
|
+
|
|
11
|
+
const GITHUB_REPO = "muhammedaksam/easiarr"
|
|
12
|
+
const CACHE_FILE = join(homedir(), ".easiarr", ".update-cache.json")
|
|
13
|
+
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
14
|
+
|
|
15
|
+
export interface UpdateInfo {
|
|
16
|
+
currentVersion: string
|
|
17
|
+
latestVersion: string
|
|
18
|
+
updateAvailable: boolean
|
|
19
|
+
releaseUrl: string
|
|
20
|
+
releaseNotes?: string
|
|
21
|
+
publishedAt?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CachedUpdate {
|
|
25
|
+
checkedAt: string
|
|
26
|
+
latestVersion: string
|
|
27
|
+
releaseUrl: string
|
|
28
|
+
releaseNotes?: string
|
|
29
|
+
publishedAt?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get cached update info if still valid
|
|
34
|
+
*/
|
|
35
|
+
function getCachedUpdate(): CachedUpdate | null {
|
|
36
|
+
if (!existsSync(CACHE_FILE)) return null
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const content = readFileSync(CACHE_FILE, "utf-8")
|
|
40
|
+
const cached = JSON.parse(content) as CachedUpdate
|
|
41
|
+
|
|
42
|
+
const checkedAt = new Date(cached.checkedAt).getTime()
|
|
43
|
+
const now = Date.now()
|
|
44
|
+
|
|
45
|
+
// Cache still valid
|
|
46
|
+
if (now - checkedAt < CACHE_DURATION_MS) {
|
|
47
|
+
return cached
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Ignore cache errors
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save update info to cache
|
|
58
|
+
*/
|
|
59
|
+
function cacheUpdate(info: Omit<CachedUpdate, "checkedAt">): void {
|
|
60
|
+
try {
|
|
61
|
+
const cached: CachedUpdate = {
|
|
62
|
+
...info,
|
|
63
|
+
checkedAt: new Date().toISOString(),
|
|
64
|
+
}
|
|
65
|
+
writeFileSync(CACHE_FILE, JSON.stringify(cached, null, 2), "utf-8")
|
|
66
|
+
} catch {
|
|
67
|
+
// Ignore cache write errors
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compare semver versions
|
|
73
|
+
* Returns true if v2 is newer than v1
|
|
74
|
+
*/
|
|
75
|
+
function isNewerVersion(v1: string, v2: string): boolean {
|
|
76
|
+
const normalize = (v: string) => v.replace(/^v/, "")
|
|
77
|
+
const parts1 = normalize(v1).split(".").map(Number)
|
|
78
|
+
const parts2 = normalize(v2).split(".").map(Number)
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
|
81
|
+
const p1 = parts1[i] || 0
|
|
82
|
+
const p2 = parts2[i] || 0
|
|
83
|
+
if (p2 > p1) return true
|
|
84
|
+
if (p2 < p1) return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fetch latest release from GitHub API
|
|
92
|
+
*/
|
|
93
|
+
async function fetchLatestRelease(): Promise<{
|
|
94
|
+
version: string
|
|
95
|
+
url: string
|
|
96
|
+
notes?: string
|
|
97
|
+
publishedAt?: string
|
|
98
|
+
} | null> {
|
|
99
|
+
try {
|
|
100
|
+
const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
|
101
|
+
headers: {
|
|
102
|
+
Accept: "application/vnd.github.v3+json",
|
|
103
|
+
"User-Agent": "easiarr-update-checker",
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const data = (await response.json()) as {
|
|
112
|
+
tag_name: string
|
|
113
|
+
html_url: string
|
|
114
|
+
body?: string
|
|
115
|
+
published_at?: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
version: data.tag_name,
|
|
120
|
+
url: data.html_url,
|
|
121
|
+
notes: data.body,
|
|
122
|
+
publishedAt: data.published_at,
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check for updates
|
|
131
|
+
* Uses cache to avoid repeated API calls
|
|
132
|
+
*/
|
|
133
|
+
export async function checkForUpdates(): Promise<UpdateInfo> {
|
|
134
|
+
const currentVersion = VersionInfo.version
|
|
135
|
+
|
|
136
|
+
// Check cache first
|
|
137
|
+
const cached = getCachedUpdate()
|
|
138
|
+
if (cached) {
|
|
139
|
+
return {
|
|
140
|
+
currentVersion,
|
|
141
|
+
latestVersion: cached.latestVersion,
|
|
142
|
+
updateAvailable: isNewerVersion(currentVersion, cached.latestVersion),
|
|
143
|
+
releaseUrl: cached.releaseUrl,
|
|
144
|
+
releaseNotes: cached.releaseNotes,
|
|
145
|
+
publishedAt: cached.publishedAt,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fetch from GitHub
|
|
150
|
+
const release = await fetchLatestRelease()
|
|
151
|
+
|
|
152
|
+
if (!release) {
|
|
153
|
+
return {
|
|
154
|
+
currentVersion,
|
|
155
|
+
latestVersion: currentVersion,
|
|
156
|
+
updateAvailable: false,
|
|
157
|
+
releaseUrl: `https://github.com/${GITHUB_REPO}/releases`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Cache the result
|
|
162
|
+
cacheUpdate({
|
|
163
|
+
latestVersion: release.version,
|
|
164
|
+
releaseUrl: release.url,
|
|
165
|
+
releaseNotes: release.notes,
|
|
166
|
+
publishedAt: release.publishedAt,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
currentVersion,
|
|
171
|
+
latestVersion: release.version,
|
|
172
|
+
updateAvailable: isNewerVersion(currentVersion, release.version),
|
|
173
|
+
releaseUrl: release.url,
|
|
174
|
+
releaseNotes: release.notes,
|
|
175
|
+
publishedAt: release.publishedAt,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Force refresh update check (ignores cache)
|
|
181
|
+
*/
|
|
182
|
+
export async function forceCheckForUpdates(): Promise<UpdateInfo> {
|
|
183
|
+
const currentVersion = VersionInfo.version
|
|
184
|
+
const release = await fetchLatestRelease()
|
|
185
|
+
|
|
186
|
+
if (!release) {
|
|
187
|
+
return {
|
|
188
|
+
currentVersion,
|
|
189
|
+
latestVersion: currentVersion,
|
|
190
|
+
updateAvailable: false,
|
|
191
|
+
releaseUrl: `https://github.com/${GITHUB_REPO}/releases`,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
cacheUpdate({
|
|
196
|
+
latestVersion: release.version,
|
|
197
|
+
releaseUrl: release.url,
|
|
198
|
+
releaseNotes: release.notes,
|
|
199
|
+
publishedAt: release.publishedAt,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
currentVersion,
|
|
204
|
+
latestVersion: release.version,
|
|
205
|
+
updateAvailable: isNewerVersion(currentVersion, release.version),
|
|
206
|
+
releaseUrl: release.url,
|
|
207
|
+
releaseNotes: release.notes,
|
|
208
|
+
publishedAt: release.publishedAt,
|
|
209
|
+
}
|
|
210
|
+
}
|