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
4 changes: 2 additions & 2 deletions packaging/mac-client/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.9.0</string>
<string>0.10.0</string>
<key>CFBundleVersion</key>
<string>0.9.0</string>
<string>0.10.0</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSMinimumSystemVersion</key>
Expand Down
169 changes: 169 additions & 0 deletions packaging/mac-client/Sources/Lisa/AboutWindow.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
49 changes: 46 additions & 3 deletions packaging/mac-client/Sources/Lisa/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?) {
Expand All @@ -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…",
Expand Down
130 changes: 130 additions & 0 deletions packaging/mac-client/Sources/Lisa/Updater.swift
Original file line number Diff line number Diff line change
@@ -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..<max(pa.count, pb.count) {
let x = i < pa.count ? pa[i] : 0
let y = i < pb.count ? pb[i] : 0
if x != y { return x > 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 }
}
}
2 changes: 2 additions & 0 deletions website/src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
];
Expand Down
Loading