From eea81c2385798ee2766e4a0921cf2cda10dcb52c Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 18 Jun 2026 16:03:32 +0800 Subject: [PATCH] feat(mac): About window with changelog link + update discovery; website /changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a version-upgrade mechanism to the Mac app's About screen and the branded changelog it links to. Mac app (packaging/mac-client): - Updater.swift — dependency-free update discovery: GET the latest GitHub release, semver-compare to the bundle version, surface the notarized DMG asset (or release page). Launch-time discovery is throttled to once/day and once per version (never nags). Deliberately not Sparkle: it discovers + opens the download (Gatekeeper verifies the notarized DMG) rather than hand-rolling an in-place binary swap (the RCE surface we don't want). - AboutWindow.swift — custom About window (replaces the standard panel): icon / name / version / MIT link, a "Changelog" button → meetlisa.ai/changelog, and an in-place "Check for Updates" → "Download vX" flow with status. - AppDelegate — About menu opens the custom window; new "Check for Updates…" menu item; quiet launch discovery shows a gentle Download/Changelog/Later prompt only when a newer release exists. Info.plist fallback → 0.10.0 (the build still stamps the real version from package.json). Website (so the link resolves): - /changelog (+ /zh-CN/changelog) — builds the changelog from the GitHub Releases API at build time (fail-safe → GitHub fallback), rendered as terminal cards matching the site. lib/releases.ts is the shared fetch. Nav gains "changelog" in both languages. Verified: `swift build` clean; `npm run build` (website) renders 10 real release cards (v0.10.0 → v0.2.0), no fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- packaging/mac-client/Resources/Info.plist | 4 +- .../mac-client/Sources/Lisa/AboutWindow.swift | 169 ++++++++++++++++++ .../mac-client/Sources/Lisa/AppDelegate.swift | 49 ++++- .../mac-client/Sources/Lisa/Updater.swift | 130 ++++++++++++++ website/src/layouts/Base.astro | 2 + website/src/lib/releases.ts | 38 ++++ website/src/pages/changelog.astro | 42 +++++ website/src/pages/zh-CN/changelog.astro | 42 +++++ 8 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 packaging/mac-client/Sources/Lisa/AboutWindow.swift create mode 100644 packaging/mac-client/Sources/Lisa/Updater.swift create mode 100644 website/src/lib/releases.ts create mode 100644 website/src/pages/changelog.astro create mode 100644 website/src/pages/zh-CN/changelog.astro diff --git a/packaging/mac-client/Resources/Info.plist b/packaging/mac-client/Resources/Info.plist index fa80e4a..5f3afee 100644 --- a/packaging/mac-client/Resources/Info.plist +++ b/packaging/mac-client/Resources/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.9.0 + 0.10.0 CFBundleVersion - 0.9.0 + 0.10.0 CFBundleIconFile AppIcon LSMinimumSystemVersion diff --git a/packaging/mac-client/Sources/Lisa/AboutWindow.swift b/packaging/mac-client/Sources/Lisa/AboutWindow.swift new file mode 100644 index 0000000..277b965 --- /dev/null +++ b/packaging/mac-client/Sources/Lisa/AboutWindow.swift @@ -0,0 +1,169 @@ +// +// AboutWindow.swift +// Lisa +// +// Custom About window (replaces the standard about panel) so it can host the +// update affordances the standard panel can't: a "Changelog" link to +// meetlisa.ai and an in-place "Check for Updates" → "Download" flow. +// + +import AppKit + +final class AboutWindowController: NSWindowController { + static let shared = AboutWindowController() + + private let statusLabel = NSTextField(labelWithString: "") + private lazy var checkButton = NSButton(title: "Check for Updates", target: self, action: #selector(checkForUpdates)) + private lazy var downloadButton = NSButton(title: "Download", target: self, action: #selector(downloadUpdate)) + private var pendingDownloadURL: URL? + + private convenience init() { + let win = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 360), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + win.titleVisibility = .hidden + win.titlebarAppearsTransparent = true + win.isMovableByWindowBackground = true + win.isReleasedWhenClosed = false + self.init(window: win) + win.contentView = buildContent() + win.center() + } + + /// Show the About window in its resting state (manual check ready). + func show() { + statusLabel.stringValue = "" + downloadButton.isHidden = true + pendingDownloadURL = nil + showWindow(nil) + window?.center() + NSApp.activate(ignoringOtherApps: true) + } + + /// Show it already presenting a discovered update (from launch discovery). + func show(update: UpdateInfo) { + show() + present(update) + } + + // MARK: - layout + + private func buildContent() -> NSView { + let icon = NSImageView(image: NSApp.applicationIconImage ?? NSImage()) + icon.imageScaling = .scaleProportionallyUpOrDown + icon.translatesAutoresizingMaskIntoConstraints = false + icon.widthAnchor.constraint(equalToConstant: 96).isActive = true + icon.heightAnchor.constraint(equalToConstant: 96).isActive = true + + let name = NSTextField(labelWithString: "Lisa") + name.font = .systemFont(ofSize: 22, weight: .bold) + name.alignment = .center + + let version = NSTextField(labelWithString: "Version \(Updater.shared.currentVersion)") + version.font = .systemFont(ofSize: 12) + version.textColor = .secondaryLabelColor + version.alignment = .center + + let license = linkButton("MIT — github.com/oratis/LISA", + url: URL(string: "https://github.com/oratis/LISA")!) + + statusLabel.font = .systemFont(ofSize: 11) + statusLabel.textColor = .secondaryLabelColor + statusLabel.alignment = .center + statusLabel.maximumNumberOfLines = 3 + statusLabel.lineBreakMode = .byWordWrapping + statusLabel.preferredMaxLayoutWidth = 320 + + let changelog = NSButton(title: "Changelog", target: self, action: #selector(openChangelog)) + changelog.bezelStyle = .rounded + checkButton.bezelStyle = .rounded + downloadButton.bezelStyle = .rounded + downloadButton.isHidden = true + + let buttonRow = NSStackView(views: [changelog, checkButton]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 12 + + let stack = NSStackView(views: [icon, name, version, license, statusLabel, downloadButton, buttonRow]) + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = 8 + stack.setCustomSpacing(14, after: icon) + stack.setCustomSpacing(16, after: license) + stack.setCustomSpacing(14, after: statusLabel) + stack.translatesAutoresizingMaskIntoConstraints = false + + let container = NSView() + container.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: container.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -24), + ]) + return container + } + + /// A borderless button styled as a hyperlink (reliable + accessible vs. a + /// gesture-recognized label). + private func linkButton(_ text: String, url: URL) -> NSButton { + let b = NSButton(title: text, target: self, action: #selector(openLink(_:))) + b.isBordered = false + b.bezelStyle = .inline + b.contentTintColor = .linkColor + b.attributedTitle = NSAttributedString(string: text, attributes: [ + .foregroundColor: NSColor.linkColor, + .font: NSFont.systemFont(ofSize: 11), + ]) + b.toolTip = url.absoluteString + linkURLs[ObjectIdentifier(b)] = url + return b + } + private var linkURLs: [ObjectIdentifier: URL] = [:] + + // MARK: - actions + + @objc private func openLink(_ sender: NSButton) { + if let u = linkURLs[ObjectIdentifier(sender)] { NSWorkspace.shared.open(u) } + } + + @objc private func openChangelog() { + NSWorkspace.shared.open(Updater.changelogURL) + } + + @objc private func downloadUpdate() { + if let u = pendingDownloadURL { NSWorkspace.shared.open(u) } + } + + @objc func checkForUpdates() { + checkButton.isEnabled = false + downloadButton.isHidden = true + statusLabel.textColor = .secondaryLabelColor + statusLabel.stringValue = "Checking for updates…" + Updater.shared.check { [weak self] result in + guard let self = self else { return } + self.checkButton.isEnabled = true + switch result { + case .upToDate(let current): + self.statusLabel.textColor = .secondaryLabelColor + self.statusLabel.stringValue = "You're on the latest version (\(current))." + case .available(let info): + self.present(info) + case .failed(let why): + self.statusLabel.textColor = .secondaryLabelColor + self.statusLabel.stringValue = "Update check failed: \(why)" + } + } + } + + private func present(_ info: UpdateInfo) { + statusLabel.textColor = .controlAccentColor + statusLabel.stringValue = "Update available: \(info.tag) (you have \(Updater.shared.currentVersion))" + pendingDownloadURL = info.downloadURL + downloadButton.title = "Download \(info.tag)" + downloadButton.isHidden = false + } +} diff --git a/packaging/mac-client/Sources/Lisa/AppDelegate.swift b/packaging/mac-client/Sources/Lisa/AppDelegate.swift index 4f6d7b4..2867a00 100644 --- a/packaging/mac-client/Sources/Lisa/AppDelegate.swift +++ b/packaging/mac-client/Sources/Lisa/AppDelegate.swift @@ -26,6 +26,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { installMenu() showMainWindow() + // Quiet update discovery — throttled to once/day and surfaced at most + // once per version, so it never nags. Only fires when a newer GitHub + // release exists than this bundle's version. + Updater.shared.discoverInBackground { [weak self] info in + self?.presentUpdatePrompt(info) + } + // Phase 3.5 — menu bar mirror of Claude Code activity. // Click the status item to bring the main window to front. MenuBarController.shared.install { [weak self] in @@ -137,6 +144,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate { mainWindow?.reload() } + // MARK: - About / Updates + + @objc func showAbout(_ sender: Any?) { + AboutWindowController.shared.show() + } + + @objc func checkForUpdatesMenu(_ sender: Any?) { + AboutWindowController.shared.show() + AboutWindowController.shared.checkForUpdates() + } + + /// A gentle, once-per-version prompt when launch discovery finds a newer + /// release. Download installs the notarized DMG (Gatekeeper verifies it). + private func presentUpdatePrompt(_ info: UpdateInfo) { + let alert = NSAlert() + alert.messageText = "Lisa \(info.tag) is available" + alert.informativeText = "You have \(Updater.shared.currentVersion). Download the new version, or see what changed." + alert.addButton(withTitle: "Download") + alert.addButton(withTitle: "Changelog") + alert.addButton(withTitle: "Later") + switch alert.runModal() { + case .alertFirstButtonReturn: NSWorkspace.shared.open(info.downloadURL) + case .alertSecondButtonReturn: NSWorkspace.shared.open(Updater.changelogURL) + default: break + } + } + // MARK: - Settings @objc func openPreferences(_ sender: Any?) { @@ -156,11 +190,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // ── App menu ──────────────────────────────────────────────── let appMenuItem = NSMenuItem() let appMenu = NSMenu() - appMenu.addItem(NSMenuItem( + let aboutItem = NSMenuItem( title: "About Lisa", - action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + action: #selector(showAbout(_:)), keyEquivalent: "" - )) + ) + aboutItem.target = self + appMenu.addItem(aboutItem) + let updatesItem = NSMenuItem( + title: "Check for Updates…", + action: #selector(checkForUpdatesMenu(_:)), + keyEquivalent: "" + ) + updatesItem.target = self + appMenu.addItem(updatesItem) appMenu.addItem(.separator()) let settingsItem = NSMenuItem( title: "Settings…", diff --git a/packaging/mac-client/Sources/Lisa/Updater.swift b/packaging/mac-client/Sources/Lisa/Updater.swift new file mode 100644 index 0000000..2bfaa37 --- /dev/null +++ b/packaging/mac-client/Sources/Lisa/Updater.swift @@ -0,0 +1,130 @@ +// +// Updater.swift +// Lisa +// +// Lightweight, dependency-free update discovery. Asks the GitHub Releases API +// for the latest tag, compares it to this bundle's version, and — when newer — +// hands back the signed/notarized DMG asset (or the release page) to download. +// +// This is deliberately NOT a Sparkle-style in-place auto-updater: it discovers +// + opens the download, and Gatekeeper verifies the notarized DMG on install. +// (A one-click in-place update would mean fetching + executing a new app +// binary ourselves — the exact RCE surface we don't want to hand-roll.) +// + +import AppKit + +/// A newer release worth offering. +struct UpdateInfo { + let version: String // "0.11.0" + let tag: String // "v0.11.0" + let downloadURL: URL // DMG asset, else the release page + let notes: String // release body (may be empty) +} + +enum UpdateCheckResult { + case upToDate(current: String) + case available(UpdateInfo) + case failed(String) +} + +final class Updater { + static let shared = Updater() + private init() {} + + /// Where the "Changelog" button goes — the branded page on the product site. + static let changelogURL = URL(string: "https://meetlisa.ai/changelog")! + /// Fallback when a release carries no DMG asset. + static let releasesPage = URL(string: "https://github.com/oratis/LISA/releases/latest")! + + private let releasesAPI = URL(string: "https://api.github.com/repos/oratis/LISA/releases/latest")! + private let lastCheckKey = "ai.meetlisa.updater.lastCheck" + private let lastNotifiedKey = "ai.meetlisa.updater.lastNotifiedVersion" + + /// This bundle's marketing version (stamped from package.json at build time). + var currentVersion: String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" + } + + /// Active check (from the About window / menu). Completion on the main thread. + func check(completion: @escaping (UpdateCheckResult) -> Void) { + var req = URLRequest(url: releasesAPI) + req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + req.timeoutInterval = 12 + let finish: (UpdateCheckResult) -> Void = { r in DispatchQueue.main.async { completion(r) } } + + URLSession.shared.dataTask(with: req) { [weak self] data, _, err in + guard let self else { return } + if let err = err { finish(.failed(err.localizedDescription)); return } + guard let data = data, + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let tag = json["tag_name"] as? String + else { finish(.failed("Couldn't read the latest release.")); return } + + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: self.lastCheckKey) + let latest = Self.stripV(tag) + if Self.isNewer(latest, than: self.currentVersion) { + let info = UpdateInfo( + version: latest, + tag: tag, + downloadURL: Self.dmgAsset(from: json) ?? Self.htmlURL(json) ?? Self.releasesPage, + notes: (json["body"] as? String) ?? "" + ) + finish(.available(info)) + } else { + finish(.upToDate(current: self.currentVersion)) + } + }.resume() + } + + /// Silent launch discovery: throttled to once/day, and only surfaces a given + /// version ONCE (never nags about the same release every launch). + func discoverInBackground(onNew: @escaping (UpdateInfo) -> Void) { + let last = UserDefaults.standard.double(forKey: lastCheckKey) + if last > Date().timeIntervalSince1970 - 24 * 60 * 60 { return } + check { [weak self] result in + guard let self, case .available(let info) = result else { return } + if UserDefaults.standard.string(forKey: self.lastNotifiedKey) == info.version { return } + UserDefaults.standard.set(info.version, forKey: self.lastNotifiedKey) + onNew(info) + } + } + + // MARK: - helpers + + private static func stripV(_ tag: String) -> String { + tag.hasPrefix("v") ? String(tag.dropFirst()) : tag + } + + private static func htmlURL(_ json: [String: Any]) -> URL? { + (json["html_url"] as? String).flatMap(URL.init(string:)) + } + + private static func dmgAsset(from json: [String: Any]) -> URL? { + guard let assets = json["assets"] as? [[String: Any]] else { return nil } + for a in assets { + if let name = a["name"] as? String, name.lowercased().hasSuffix(".dmg"), + let u = a["browser_download_url"] as? String { + return URL(string: u) + } + } + return nil + } + + /// Is `a` a newer version than `b`? Numeric, per dotted component; ignores a + /// pre-release/build suffix (1.2.3-rc1 → 1.2.3). 0.10.0 > 0.9.0. + static func isNewer(_ a: String, than b: String) -> Bool { + let pa = parse(a), pb = parse(b) + for i in 0.. y } + } + return false + } + + private static func parse(_ v: String) -> [Int] { + let core = v.split(whereSeparator: { $0 == "-" || $0 == "+" }).first.map(String.init) ?? v + return core.split(separator: ".").map { Int($0) ?? 0 } + } +} diff --git a/website/src/layouts/Base.astro b/website/src/layouts/Base.astro index dd35a18..10e1232 100644 --- a/website/src/layouts/Base.astro +++ b/website/src/layouts/Base.astro @@ -18,12 +18,14 @@ const navItems = lang === "zh-CN" ? [ { href: "/zh-CN/", label: "首页" }, { href: "/zh-CN/install", label: "安装" }, + { href: "/zh-CN/changelog", label: "更新日志" }, { href: "/zh-CN/moods", label: "表情画廊" }, { href: "https://github.com/oratis/LISA", label: "GitHub", external: true }, ] : [ { href: "/", label: "home" }, { href: "/install", label: "install" }, + { href: "/changelog", label: "changelog" }, { href: "/moods", label: "moods" }, { href: "https://github.com/oratis/LISA", label: "github", external: true }, ]; diff --git a/website/src/lib/releases.ts b/website/src/lib/releases.ts new file mode 100644 index 0000000..b2f3453 --- /dev/null +++ b/website/src/lib/releases.ts @@ -0,0 +1,38 @@ +/** + * Fetch published GitHub releases for the changelog page, at BUILD time. + * + * Build-time (not client-side) so visitors load static HTML — no API rate + * limits, no JS. Fail-safe: any network/parse error returns [] so the site + * build never breaks; the page then shows a "see releases on GitHub" fallback. + */ +export interface Release { + name: string; + tag: string; + date: string; // YYYY-MM-DD + url: string; + body: string; +} + +const RELEASES_API = "https://api.github.com/repos/oratis/LISA/releases?per_page=30"; + +export async function fetchReleases(): Promise { + try { + const res = await fetch(RELEASES_API, { + headers: { accept: "application/vnd.github+json", "user-agent": "lisa-website" }, + }); + if (!res.ok) return []; + const raw = (await res.json()) as Array>; + if (!Array.isArray(raw)) return []; + return raw + .filter((r) => r && r.draft !== true) + .map((r) => ({ + name: (r.name as string) || (r.tag_name as string) || "release", + tag: (r.tag_name as string) || "", + date: typeof r.published_at === "string" ? r.published_at.slice(0, 10) : "", + url: (r.html_url as string) || "https://github.com/oratis/LISA/releases", + body: ((r.body as string) || "").trim(), + })); + } catch { + return []; + } +} diff --git a/website/src/pages/changelog.astro b/website/src/pages/changelog.astro new file mode 100644 index 0000000..20476cc --- /dev/null +++ b/website/src/pages/changelog.astro @@ -0,0 +1,42 @@ +--- +import Base from "../layouts/Base.astro"; +import { fetchReleases } from "../lib/releases"; + +// Fetched at build time (fail-safe → [] → GitHub fallback below). +const releases = await fetchReleases(); +--- + +

Changelog

+

+ Every Lisa release, newest first. Grab the latest from install, + or browse GitHub Releases ↗. + The Mac app checks here too — Lisa ▸ About ▸ Check for Updates. +

+ + {releases.length === 0 ? ( +

+ Couldn't load releases right now — see them on + GitHub ↗. +

+ ) : ( + releases.map((r) => ( +
+
+ + {r.tag || r.name}{r.date ? ` · ${r.date}` : ""} +
+
+ {r.body + ?
{r.body}
+ :

No notes for this release.

} +

View {r.tag || "release"} on GitHub ↗

+
+
+ )) + )} + + + diff --git a/website/src/pages/zh-CN/changelog.astro b/website/src/pages/zh-CN/changelog.astro new file mode 100644 index 0000000..bb78fb0 --- /dev/null +++ b/website/src/pages/zh-CN/changelog.astro @@ -0,0 +1,42 @@ +--- +import Base from "../../layouts/Base.astro"; +import { fetchReleases } from "../../lib/releases"; + +// 构建时拉取(失败安全 → [] → 回退到 GitHub)。 +const releases = await fetchReleases(); +--- + +

更新日志

+

+ 每个 Lisa 版本,最新在前。最新版从 安装页 获取, + 或浏览 GitHub Releases ↗。 + Mac 应用也会查这里 —— Lisa ▸ 关于 ▸ 检查更新。 +

+ + {releases.length === 0 ? ( +

+ 暂时无法加载发布信息 —— 请到 + GitHub ↗ 查看。 +

+ ) : ( + releases.map((r) => ( +
+
+ + {r.tag || r.name}{r.date ? ` · ${r.date}` : ""} +
+
+ {r.body + ?
{r.body}
+ :

该版本无发布说明。

} +

在 GitHub 查看 {r.tag || "release"} ↗

+
+
+ )) + )} + + +