diff --git a/aesel/src/native-notify.mjs b/aesel/src/native-notify.mjs new file mode 100644 index 0000000000..733e0ac081 --- /dev/null +++ b/aesel/src/native-notify.mjs @@ -0,0 +1,84 @@ +// Native notifications for the Aesel TUI, and its row in the AC network device +// registry (POST /api/app-device). +// +// On macOS a finished turn, a failure or a waiting approval posts a native +// notification, but only while the terminal running Aesel is not the frontmost +// app. When the Aesel Mac app is running, the notification goes through it +// (`aesel://notify`), so it carries Aesel's name and icon; otherwise it falls +// back to `osascript`. Focus is read with `lsappinfo` and the process's parent +// chain, so no Automation or Accessibility permission is needed. +// AESEL_NOTIFY=0 turns notifications off. +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { arch, homedir, release } from "node:os"; +import { dirname, join } from "node:path"; + +const GUI_BUNDLE = "computer.aesthetic.easel"; +// Resolves stdout, or null when the command failed. +const run = (file, args) => new Promise(resolve => + execFile(file, args, { timeout: 3000 }, (error, stdout) => resolve(error ? null : String(stdout)))); + +async function ancestors(pid = process.pid) { + const chain = new Set(); + for (let i = 0; i < 24 && pid > 1; i++) { + chain.add(pid); + pid = Number((await run("/bin/ps", ["-o", "ppid=", "-p", String(pid)]) || "").trim()) || 0; + } + return chain; +} + +// True when the frontmost app is this process's terminal (one of its ancestors). +export async function terminalFocused({ lsappinfo = run, chain = ancestors } = {}) { + const front = (await lsappinfo("/usr/bin/lsappinfo", ["front"]) || "").trim(); + if (!front) return false; + const pid = Number((await lsappinfo("/usr/bin/lsappinfo", ["info", "-only", "pid", front]) || "").match(/(\d+)/)?.[1]); + return Number.isInteger(pid) && (await chain()).has(pid); +} + +export function notifyURL({ title, body = "", kind = "done" }) { + const query = new URLSearchParams({ title: title.slice(0, 120), body: body.slice(0, 240), kind, from: "tui" }); + return `aesel://notify?${query}`; +} + +export async function notifyNative({ title, body = "", kind = "done" }, { platform = process.platform, env = process.env, exec = run } = {}) { + if (platform !== "darwin" || env.AESEL_NOTIFY === "0") return "off"; + if (await terminalFocused({ lsappinfo: exec })) return "focused"; + const text = String(body).replace(/\s+/g, " ").trim(); + // An older Aesel.app without the aesel:// scheme makes `open` fail; fall through. + if ((await exec("/usr/bin/lsappinfo", ["find", `bundleid=${GUI_BUNDLE}`]) || "").trim() && + await exec("/usr/bin/open", ["-g", notifyURL({ title, body: text, kind })]) !== null) return "aesel"; + // argv, not string interpolation: titles and replies may contain quotes. + await exec("/usr/bin/osascript", ["-e", "on run argv", "-e", "display notification (item 2 of argv) with title (item 1 of argv)", "-e", "end run", title, text.slice(0, 240)]); + return "osascript"; +} + +// One id per machine for the TUI, kept beside Aesel's other local state. +function deviceId(home = homedir()) { + const file = join(home, ".config", "aesel", "device-id"); + try { return readFileSync(file, "utf8").trim(); } catch {} + const id = randomUUID(); + try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, id, { mode: 0o600 }); } catch {} + return id; +} + +const PLATFORMS = { darwin: "mac", linux: "linux", win32: "windows" }; + +export function deviceReport(event, { version, platform = process.platform } = {}) { + return { + app: "aesel", deviceId: deviceId(), event, platform: PLATFORMS[platform] || "linux", + label: "Aesel TUI", os: `${platform} ${release()}`.slice(0, 40), model: arch(), + ...(version && /^\d+(?:\.\d+){0,3}$/.test(version) ? { version } : {}), + }; +} + +// Best effort; a registry outage never touches the terminal. +export async function reportDevice(event, { token, version, fetch = globalThis.fetch } = {}) { + try { + await fetch("https://aesthetic.computer/api/app-device", { + method: "POST", signal: AbortSignal.timeout(8000), + headers: { "Content-Type": "application/json", ...(token && event !== "logout" ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify(deviceReport(event, { version })), + }); + } catch {} +} diff --git a/aesel/src/tui.mjs b/aesel/src/tui.mjs index a99e47e8ba..b6c9d93dc6 100755 --- a/aesel/src/tui.mjs +++ b/aesel/src/tui.mjs @@ -67,6 +67,7 @@ import { cleanText, clipText, color, aeselInk, renderBoot, renderFrame, renderGe import { mascotNextFrameIn, mascotRowNextFrameIn } from "./mascot.mjs"; import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; +import { notifyNative, reportDevice } from "./native-notify.mjs"; import { mediaPaths, mediaFile, watchMedia, itemText, mediaChanged } from "./media.mjs"; import { watchSource } from "./source-watch.mjs"; import { planMime, postMime, MAX_BYTES } from "./mime.mjs"; @@ -126,6 +127,9 @@ if (continuingId && !/^[a-f0-9-]{36}$/i.test(continuingId)) throw new Error("Inv const slabSession = new SlabSession({ cwd, pro, private: profile.private, ...(continuingId ? {sessionId: continuingId} : {}) }); slabSession.start(); slabSession.identity(session.handle); +// The AC network device registry: this machine runs the Aesel TUI. +const aeselVersion = (() => { try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; } catch { return undefined; } })(); +if (!process.env.AESEL_DESKTOP) void session.token().then(token => reportDevice("open", { token, version: aeselVersion }), () => {}); process.once("exit", () => slabSession.close()); let sharingAcknowledgment; // A private session never joins the required transcript sharing — nothing @@ -1894,6 +1898,12 @@ function handleNotification({ method, params = {} }) { if (params.turn?.status === "interrupted") slabSession.interrupted(); else if (params.turn?.status === "failed") slabSession.awaitingInput("easel turn failed"); else slabSession.complete(); + // Native notification when the terminal is not frontmost. Short turns stay + // quiet: if it finished in seconds you were probably still looking. + if (!process.env.AESEL_DESKTOP && params.turn?.status !== "interrupted") { + if (params.turn?.status === "failed") void notifyNative({ title: "Aesel stopped with a problem", body: failure?.message || "The turn failed.", kind: "failed" }); + else if (Date.now() - (state.turnActivity?.startedAt || state.requestStartedAt || Date.now()) >= 8000) void notifyNative({ title: "Aesel is done", body: finalReply?.text || "", kind: "done" }); + } // The turn's words, whole, now that there are no more of them. for (const id of turnAssistant) { const entry = state.entries.find((candidate) => candidate.id === id); @@ -1973,11 +1983,13 @@ function handleNotification({ method, params = {} }) { const approvalQueue = new ApprovalQueue(); +let notifiedApproval = null; function showPendingApproval() { const pending = approvalQueue.current; state.approval = pending ? {id:pending.id, method:pending.method, subject:pending.subject, choicesText:pending.choicesText} : null; if (state.approval) { slabSession.awaitingInput("easel needs approval"); + if (!process.env.AESEL_DESKTOP && notifiedApproval !== pending.id) { notifiedApproval = pending.id; void notifyNative({ title: "Aesel needs your approval", body: pending.subject || "", kind: "approval" }); } state.status = "approval"; } else { slabSession.resumeWork(); diff --git a/aesel/test/native-notify.test.mjs b/aesel/test/native-notify.test.mjs new file mode 100644 index 0000000000..10e0e9af57 --- /dev/null +++ b/aesel/test/native-notify.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { notifyNative, notifyURL, terminalFocused, deviceReport } from "../src/native-notify.mjs"; + +// A fake exec: the front app's pid, whether the Aesel GUI runs, and a log. +function system({ frontPid = 999, gui = false } = {}) { + const calls = []; + const exec = async (file, args) => { + calls.push([file, ...args]); + if (file.endsWith("lsappinfo") && args[0] === "front") return "ASN:0x0-0x1234:\n"; + if (file.endsWith("lsappinfo") && args[0] === "info") return `"pid"=${frontPid}\n`; + if (file.endsWith("lsappinfo") && args[0] === "find") return gui ? "ASN:0x0-0x99:\n" : ""; + if (file.endsWith("ps")) return "1\n"; + return ""; + }; + return { exec, calls }; +} + +test("a focused terminal gets no notification", async () => { + assert.equal(await terminalFocused({ lsappinfo: system({ frontPid: 42 }).exec, chain: async () => new Set([42, 7]) }), true); + assert.equal(await terminalFocused({ lsappinfo: system({ frontPid: 99 }).exec, chain: async () => new Set([42, 7]) }), false); +}); + +test("posts through the Aesel app when it runs, osascript otherwise; off when asked", async () => { + const gui = system({ gui: true }); + assert.equal(await notifyNative({ title: "Aesel is done", body: "made a palm\ntree", kind: "done" }, { platform: "darwin", env: {}, exec: gui.exec }), "aesel"); + const open = gui.calls.find(c => c[0] === "/usr/bin/open"); + assert.equal(open[1], "-g"); + const url = new URL(open[2]); + assert.equal(url.protocol, "aesel:"); assert.equal(url.host, "notify"); + assert.equal(url.searchParams.get("body"), "made a palm tree"); + assert.equal(url.searchParams.get("from"), "tui"); + + // A running Aesel.app that predates aesel:// cannot take the URL: fall back. + const stale = system({ gui: true }); + const failingOpen = async (file, args) => file === "/usr/bin/open" ? null : stale.exec(file, args); + assert.equal(await notifyNative({ title: "t" }, { platform: "darwin", env: {}, exec: failingOpen }), "osascript"); + + const plain = system(); + assert.equal(await notifyNative({ title: 'say "hi"', body: "x" }, { platform: "darwin", env: {}, exec: plain.exec }), "osascript"); + const script = plain.calls.find(c => c[0] === "/usr/bin/osascript"); + assert.deepEqual(script.slice(-2), ['say "hi"', "x"], "title and body travel as argv, never inside the script"); + + assert.equal(await notifyNative({ title: "t" }, { platform: "linux", env: {}, exec: plain.exec }), "off"); + assert.equal(await notifyNative({ title: "t" }, { platform: "darwin", env: { AESEL_NOTIFY: "0" }, exec: plain.exec }), "off"); +}); + +test("notify URLs are bounded", () => { + const url = new URL(notifyURL({ title: "t".repeat(500), body: "b".repeat(500) })); + assert.equal(url.searchParams.get("title").length, 120); + assert.equal(url.searchParams.get("body").length, 240); +}); + +test("the TUI reports as Aesel on this machine's platform", () => { + const report = deviceReport("open", { version: "0.8.24", platform: "darwin" }); + assert.equal(report.app, "aesel"); assert.equal(report.platform, "mac"); assert.equal(report.label, "Aesel TUI"); + assert.match(report.deviceId, /^[0-9a-f-]{36}$/); assert.equal(report.version, "0.8.24"); + assert.equal(deviceReport("open", { version: "dev", platform: "linux" }).version, undefined); +}); diff --git a/apple/aesel/Aesel.xcodeproj/project.pbxproj b/apple/aesel/Aesel.xcodeproj/project.pbxproj index 25aad4ff95..04f2c3e5c0 100644 --- a/apple/aesel/Aesel.xcodeproj/project.pbxproj +++ b/apple/aesel/Aesel.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 1A0F35CCE1C51897FB881351 /* PreviewResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC974F17F071328B039F80B8 /* PreviewResize.swift */; }; 1F53641C03321161A7665F3B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E152C400C5CE69463B39F /* ContentView.swift */; }; 2026E504A366EFEB32F2F822 /* AeselModelPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26176753E43E445E2CF551D /* AeselModelPicker.swift */; }; + 208C94F5C9B9698F5ED3BFE7 /* AeselNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7801891B519EE2B10A99183E /* AeselNotifications.swift */; }; 209EDA86EAD34DFC2EFD8848 /* NotebookWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CDBBA3D80069AB001DCB170 /* NotebookWebView.swift */; }; 2177ABB4922D91CD4C857EF1 /* NotebookWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CDBBA3D80069AB001DCB170 /* NotebookWebView.swift */; }; 23BD42525B15F37C435178AB /* AeselProviderPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3405C669DD22957E6E9FC52B /* AeselProviderPicker.swift */; }; @@ -41,6 +42,7 @@ 60D63C84E3196DD0C030FF63 /* AeselSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174356CADCC7413BFE9ABF1A /* AeselSourceView.swift */; }; 61636BC5717C14D1D6A3F51F /* preview-continuity.js in Resources */ = {isa = PBXBuildFile; fileRef = F9AA1A94FD57EA03B4309E30 /* preview-continuity.js */; }; 633F0065719420EF155326A0 /* SignInPanelLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18D254092D8CB3C00DA9756 /* SignInPanelLayout.swift */; }; + 63B9A80BB7D76ACA8803EA9D /* AeselNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7801891B519EE2B10A99183E /* AeselNotifications.swift */; }; 6C328E19AB09BC33ECF7E26E /* AeselHoverSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2BE7BCB5067054611C54637 /* AeselHoverSound.swift */; }; 6E01FC52B2208E175FFC6985 /* SessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71285DFFDAE5A8B3FB1FBD5 /* SessionHost.swift */; }; 7179F7E1202917D1C1E57EA1 /* LaunchPing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92968DF5C690392605590A85 /* LaunchPing.swift */; }; @@ -108,6 +110,7 @@ 5D9AB5CF1D7C27E6930F81B1 /* Braincells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Braincells.swift; sourceTree = ""; }; 6975BD799206CFA5D9095BDB /* NativeSignIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignIn.swift; sourceTree = ""; }; 72A337044339282DB88573A0 /* AeselPieceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselPieceView.swift; sourceTree = ""; }; + 7801891B519EE2B10A99183E /* AeselNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselNotifications.swift; sourceTree = ""; }; 7CB1DC30F6BBB5B2167272D2 /* AeselScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeselScene.swift; sourceTree = ""; }; 7F4BC5239A9B70F128C1DC84 /* ApplePlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplePlatform.swift; sourceTree = ""; }; 90DE4FA98343A247299E9ACA /* NotebookDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotebookDocument.swift; sourceTree = ""; }; @@ -169,6 +172,7 @@ A2BE7BCB5067054611C54637 /* AeselHoverSound.swift */, F26176753E43E445E2CF551D /* AeselModelPicker.swift */, BB9C447ACCA3590492C05AC5 /* AeselNotebook.swift */, + 7801891B519EE2B10A99183E /* AeselNotifications.swift */, 72A337044339282DB88573A0 /* AeselPieceView.swift */, 3405C669DD22957E6E9FC52B /* AeselProviderPicker.swift */, B4D06A336C257DC6663C0E51 /* AeselProx.swift */, @@ -368,6 +372,7 @@ 928997979C8BC6E92AC2B3A9 /* AeselHoverSound.swift in Sources */, C5323C1BB9D8D59F14A1FF74 /* AeselModelPicker.swift in Sources */, A2CDE2052DDB891D3A4659D6 /* AeselNotebook.swift in Sources */, + 63B9A80BB7D76ACA8803EA9D /* AeselNotifications.swift in Sources */, 06DAB9B425E19C4E02F25A24 /* AeselPieceView.swift in Sources */, E269C78DB87E568CC6F63CC8 /* AeselProviderPicker.swift in Sources */, A10EC17AA74EA9E2FB9495B6 /* AeselProx.swift in Sources */, @@ -408,6 +413,7 @@ 6C328E19AB09BC33ECF7E26E /* AeselHoverSound.swift in Sources */, 2026E504A366EFEB32F2F822 /* AeselModelPicker.swift in Sources */, A4B8792BDBB5F95822088FB0 /* AeselNotebook.swift in Sources */, + 208C94F5C9B9698F5ED3BFE7 /* AeselNotifications.swift in Sources */, 56DF7E946D2DDC7451AA9F0E /* AeselPieceView.swift in Sources */, 23BD42525B15F37C435178AB /* AeselProviderPicker.swift in Sources */, 8C8ABE58FEAE08FF90C5AEEF /* AeselProx.swift in Sources */, diff --git a/apple/aesel/MacInfo.plist b/apple/aesel/MacInfo.plist index 450de82921..7213383afc 100644 --- a/apple/aesel/MacInfo.plist +++ b/apple/aesel/MacInfo.plist @@ -16,4 +16,14 @@ NSHighResolutionCapable NSPrincipalClassNSApplication NSAppTransportSecurityNSAllowsLocalNetworking + + CFBundleURLTypes + + + CFBundleURLName + computer.aesthetic.easel + CFBundleURLSchemes + aesel + + diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift index 607e0041e2..dbce1e343a 100644 --- a/apple/aesel/Sources/AeselApp.swift +++ b/apple/aesel/Sources/AeselApp.swift @@ -2,9 +2,13 @@ import SwiftUI @main struct AeselApp: App { + #if os(macOS) + @NSApplicationDelegateAdaptor(AeselAppDelegate.self) private var appDelegate + #endif init() { ApplePlatform.registerFonts() LaunchPing.send("aesel") + AeselNotifications.center.install() #if os(macOS) AeselTerminal.install() #endif @@ -167,3 +171,12 @@ private struct AeselWorkspace: View { } } + +#if os(macOS) +/// Receives `aesel://` URLs in AppKit so they never open a SwiftUI window. +final class AeselAppDelegate: NSObject, NSApplicationDelegate { + func application(_ application: NSApplication, open urls: [URL]) { + Task { @MainActor in urls.forEach(AeselNotifications.handle) } + } +} +#endif diff --git a/apple/aesel/Sources/AeselNotifications.swift b/apple/aesel/Sources/AeselNotifications.swift new file mode 100644 index 0000000000..b1442920c5 --- /dev/null +++ b/apple/aesel/Sources/AeselNotifications.swift @@ -0,0 +1,175 @@ +import Foundation +import UserNotifications +#if os(macOS) +import AppKit +#else +import UIKit +#endif + +/// Native notifications for the Aesel GUI, and its row in the AC network device +/// registry (POST /api/app-device). A turn that finishes, fails or waits for an +/// approval while Aesel is in the background posts a local notification; +/// clicking it brings that thread forward. Permission is asked once, when the +/// first turn starts. Debug builds stay silent unless AESEL_DEVICE_REPORTS=1. +@MainActor enum AeselNotifications { + private static let askedKey = "aesel.notifications.asked" + static let center = Delegate() + + private static var enabled: Bool { + #if DEBUG + return ProcessInfo.processInfo.environment["AESEL_DEVICE_REPORTS"] == "1" + #else + return true + #endif + } + + private static var appActive: Bool { + #if os(macOS) + return NSApplication.shared.isActive + #else + return UIApplication.shared.applicationState == .active + #endif + } + + /// Called with every session event before the renderer sees it. + static func observe(_ event: [String: Any], session: Session, token: () -> String?) { + guard enabled else { return } + switch event["type"] as? String { + case "signedIn": AeselDevices.report(.login, token: token()) + case "signedOut": AeselDevices.report(.logout, token: nil) + case "approval": + guard let approval = event["approval"] as? [String: Any], approval["id"] != nil else { return } + let detail = approval["detail"] as? String ?? (approval["params"] as? [String: Any])?["command"] as? String ?? "" + post(title: approval["title"] as? String ?? "Aesel needs your approval", body: detail, thread: session.currentThreadID, kind: "approval") + case "bridge": + let params = event["params"] as? [String: Any] ?? [:] + switch event["method"] as? String { + case "turn/started": askOnce() + case "turn/completed": + let turn = params["turn"] as? [String: Any] ?? [:] + let status = turn["status"] as? String + if status == "interrupted" { return } + let failed = status == "failed" || turn["error"] != nil + let message = (turn["error"] as? [String: Any])?["message"] as? String + let reply = session.entries.last(where: { $0.kind == .ac })?.text ?? "" + post(title: failed ? "Aesel stopped with a problem" : "Aesel is done", + body: failed ? (message ?? "The turn failed.") : reply, thread: session.currentThreadID, kind: failed ? "failed" : "done") + default: break + } + default: break + } + } + + /// `aesel://notify?title=…&body=…&kind=…&from=tui` from the Aesel TUI, which + /// already checked that its terminal is not frontmost. Asks for permission + /// first if this app never has, so TUI-only use still gets notifications. + static func handle(_ url: URL) { + guard url.scheme == "aesel", url.host == "notify", + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return } + let value = { (name: String) in items.first(where: { $0.name == name })?.value ?? "" } + let title = String(value("title").prefix(120)), kind = String(value("kind").prefix(16)) + guard !title.isEmpty else { return } + Task { + let center = UNUserNotificationCenter.current() + if await center.notificationSettings().authorizationStatus == .notDetermined { + UserDefaults.standard.set(true, forKey: askedKey) + _ = try? await center.requestAuthorization(options: [.alert, .sound]) + } + let content = UNMutableNotificationContent() + content.title = title + content.subtitle = "Aesel TUI" + content.body = String(value("body").prefix(240)) + content.sound = .default + content.threadIdentifier = "tui" + try? await center.add(UNNotificationRequest(identifier: "tui:\(kind)", content: content, trigger: nil)) + } + } + + private static func askOnce() { + guard !UserDefaults.standard.bool(forKey: askedKey) else { return } + UserDefaults.standard.set(true, forKey: askedKey) + Task { _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) } + } + + private static func post(title: String, body: String, thread: String, kind: String) { + guard !appActive else { return } + let content = UNMutableNotificationContent() + content.title = title + content.body = String(body.trimmingCharacters(in: .whitespacesAndNewlines).prefix(240)) + content.sound = .default + content.threadIdentifier = thread + content.userInfo = ["thread": thread, "kind": kind] + // One notification per thread and kind: a newer "done" replaces the last. + let request = UNNotificationRequest(identifier: "\(thread):\(kind)", content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + final class Delegate: NSObject, UNUserNotificationCenterDelegate { + func install() { UNUserNotificationCenter.current().delegate = self } + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + [.banner, .list, .sound] + } + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + guard let thread = response.notification.request.content.userInfo["thread"] as? String else { return } + await MainActor.run { SessionHost.focus(thread: thread) } + } + } +} + +/// The GUI's row in the AC network device registry. +@MainActor enum AeselDevices { + enum Event: String { case open, login, logout } + private static var opened = false + + /// macOS has no identifierForVendor; keep one id for this install. + private static var deviceId: String { + #if os(iOS) + if let id = UIDevice.current.identifierForVendor?.uuidString { return id } + #endif + let defaults = UserDefaults.standard + if let id = defaults.string(forKey: "aesel.deviceId") { return id } + let id = UUID().uuidString + defaults.set(id, forKey: "aesel.deviceId") + return id + } + + static func reportOpenOnce(token: String?) { + guard !opened else { return } + opened = true + report(.open, token: token) + } + + static func report(_ event: Event, token: String?) { + #if DEBUG + guard ProcessInfo.processInfo.environment["AESEL_DEVICE_REPORTS"] == "1" else { return } + #endif + let info = Bundle.main.infoDictionary ?? [:] + var body: [String: Any] = ["app": "aesel", "deviceId": deviceId, "event": event.rawValue, "label": "Aesel"] + #if os(macOS) + body["platform"] = "mac" + body["os"] = ProcessInfo.processInfo.operatingSystemVersionString + body["model"] = sysctl("hw.model") + #else + body["platform"] = UIDevice.current.userInterfaceIdiom == .pad ? "ipados" : "ios" + body["os"] = UIDevice.current.systemVersion + body["model"] = sysctl("hw.machine") + #endif + if let version = info["CFBundleShortVersionString"] as? String { body["version"] = version } + if let build = info["CFBundleVersion"] as? String, build.allSatisfy(\.isNumber) { body["build"] = build } + var request = URLRequest(url: URL(string: "https://aesthetic.computer/api/app-device")!, timeoutInterval: 10) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let token, event != .logout { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + Task { _ = try? await URLSession.shared.data(for: request) } // best effort + } + + private static func sysctl(_ name: String) -> String { + var size = 0 + sysctlbyname(name, nil, &size, nil, 0) + guard size > 0 else { return "" } + var bytes = [CChar](repeating: 0, count: size) + sysctlbyname(name, &bytes, &size, nil, 0) + return String(cString: bytes) + } +} diff --git a/apple/aesel/Sources/SessionHost.swift b/apple/aesel/Sources/SessionHost.swift index 8034c7b1bd..ab4d16d344 100644 --- a/apple/aesel/Sources/SessionHost.swift +++ b/apple/aesel/Sources/SessionHost.swift @@ -1,6 +1,9 @@ import Foundation import WebKit import Security +#if os(macOS) +import AppKit +#endif /// Runs the shared JavaScript session in a WKWebView that is never shown. /// @@ -53,6 +56,7 @@ final class SessionHost: NSObject { #endif super.init() Self.instances.add(self) + AeselDevices.reportOpenOnce(token: store.token()) if let issue = store.issue { session.fatal = issue } let controller = WKUserContentController() @@ -125,6 +129,17 @@ final class SessionHost: NSObject { func stop() { call("void aesel.stop();") } func newPiece() { newSession(medium: "piece") } func newSession(medium: String) { call("void aesel.newPiece(\(quote(medium)));") } + /// A notification click: bring forward the window showing `thread`, or + /// resume it in the first window. + static func focus(thread: String) { + let hosts = instances.allObjects + guard let host = hosts.first(where: { $0.session.currentThreadID == thread }) ?? hosts.first else { return } + if host.session.currentThreadID != thread, !host.session.busy { host.resumeSession(id: thread) } + #if os(macOS) + NSApplication.shared.activate(ignoringOtherApps: true) + #endif + } + func resumeSession(id: String) { call("void aesel.resumeSession(\(quote(id)));") } func save() { call("void aesel.save();") } func setDraft(_ text: String, threadID: String) { @@ -339,6 +354,7 @@ extension SessionHost: WKScriptMessageHandler { if type == "diagnostic" { NSLog("[aesel] %@ at %@: %@", body["operation"] as? String ?? "request", body["step"] as? String ?? "response", body["message"] as? String ?? "unknown failure") } + AeselNotifications.observe(body, session: session) { [store] in store.token() } session.receive(body) } } diff --git a/shared/app-registry.mjs b/shared/app-registry.mjs index 2f23590fee..12667e0c56 100644 --- a/shared/app-registry.mjs +++ b/shared/app-registry.mjs @@ -11,7 +11,7 @@ export const APPS = Object.freeze({ whistlegraph: { tenant: "aesthetic", apns: "computer.aesthetic.walkieware", web: false, wired: true }, aestheticcomputer: { tenant: "aesthetic", apns: "aesthetic.computer", web: true, wired: false }, - aesel: { tenant: "aesthetic", apns: "computer.aesthetic.easel", web: false, wired: false }, + aesel: { tenant: "aesthetic", apns: "computer.aesthetic.easel", web: false, wired: true }, oskiewar: { tenant: "aesthetic", apns: "computer.aesthetic.oskiewar", web: true, wired: false }, menuband: { tenant: "aesthetic", apns: "computer.aesthetic.menuband", web: false, wired: false }, "sotce-net": { tenant: "sotce", apns: null, web: true, wired: false }, diff --git a/system/netlify/functions/app-notify.mjs b/system/netlify/functions/app-notify.mjs new file mode 100644 index 0000000000..fdf5a1a5ae --- /dev/null +++ b/system/netlify/functions/app-notify.mjs @@ -0,0 +1,63 @@ +// App Notify, 2026.10.08 +// A signed-in person notifies their own devices through the AC network device +// registry (shared/push.mjs sendToTarget). It cannot reach anyone else: the +// target is always the caller's verified account, narrowed by app or device. +// +// POST /api/app-notify {title, body?, app?, deviceId?, url?, thread?} +// Authorization: Bearer +// → {attempted, succeeded, failed, pruned} +import { authorize } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { appConfig, userKey } from "../../../shared/app-registry.mjs"; +import { sendToTarget } from "../../../shared/push.mjs"; + +const HEADERS = { "Cache-Control": "no-store" }; +const PER_HOUR = 60; +const sent = new Map(); // user → timestamps in the last hour (ephemeral) + +function allowed(user, now = Date.now()) { + const recent = (sent.get(user) || []).filter(at => now - at < 3600_000); + if (recent.length >= PER_HOUR) return false; + recent.push(now); sent.set(user, recent); + if (sent.size > 10000) for (const [key, times] of sent) if (!times.some(at => now - at < 3600_000)) sent.delete(key); + return true; +} + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, "", HEADERS); + if (event.httpMethod !== "POST") return respond(405, { error: "POST required" }, HEADERS); + let body; + try { body = JSON.parse(event.body || "{}"); } catch { return respond(400, { error: "Invalid JSON" }, HEADERS); } + const { title, app, deviceId, url, thread } = body; + if (typeof title !== "string" || !title.trim() || title.length > 120) return respond(400, { error: "A title up to 120 characters is required" }, HEADERS); + if (body.body !== undefined && (typeof body.body !== "string" || body.body.length > 1000)) return respond(400, { error: "Body must be text up to 1000 characters" }, HEADERS); + if (app !== undefined && !appConfig(app)) return respond(400, { error: "Unknown app" }, HEADERS); + if (deviceId !== undefined && (!app || typeof deviceId !== "string")) return respond(400, { error: "A device needs its app" }, HEADERS); + if (url !== undefined && (typeof url !== "string" || !url.startsWith("/") || url.length > 200)) return respond(400, { error: "url must be a path" }, HEADERS); + + const tenant = appConfig(app)?.tenant || "aesthetic"; + const user = await authorize(event.headers, tenant); + if (!user?.sub) return respond(401, { error: "Sign in to send notifications" }, HEADERS); + const key = userKey(app || "aestheticcomputer", user.sub); + if (!allowed(key)) return respond(429, { error: "Notification limit reached; try again later" }, HEADERS); + + try { + const { db } = await connect(); + // A device target still has to belong to the caller. + if (deviceId) { + const owned = await db.collection("app-devices").countDocuments({ _id: `${app}:${deviceId}`, user: key }); + if (!owned) return respond(404, { error: "No such device on your account" }, HEADERS); + } + const target = deviceId ? { app, deviceId } : { user: key, ...(app ? { app } : {}) }; + const summary = await sendToTarget(db, target, { + title: title.trim(), body: (body.body || "").trim(), + ...(thread ? { thread: String(thread).slice(0, 64), collapse: String(thread).slice(0, 64) } : {}), + ...(url ? { data: { url } } : {}), + }); + return respond(200, summary, HEADERS); + } catch (error) { + console.error("app-notify:", error); + return respond(503, { error: "Notifications unavailable" }, HEADERS); + } +}