{
  "format" : 1,
  "manifest" : {
    "accent" : "amber",
    "capabilities" : [
      {
        "power" : {

        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/System/StayAwake",
    "id" : "io.navbytes.stackbar.stay-awake",
    "minimumStackbar" : "0.17.0",
    "name" : "Stay Awake",
    "summary" : "Keeps your Mac awake for a while, until a time, while an app runs or on a schedule.",
    "surfaces" : [
      "menuBar",
      "panel",
      "widget",
      "settings"
    ],
    "symbol" : "cup.and.saucer.fill",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 15
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.4.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "YpKMIexPadFJ82DUQ8P0t0dz6d0WBhcXe1N+RPF+DMK+hQWCzZI21ShiTtWnBO6db1Nb1UcItXU0lgoWzxFfDQ==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Keeps the Mac awake: for a while, until a time, while an app runs, on a\n/// weekday schedule, or until you say stop. It lets go when the battery runs\n/// low, and the host releases the assertion if the plugin ever stops or\n/// crashes, so it can't leave the Mac awake by accident.\n@main\nfinal class StayAwake: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.stay-awake\",\n        name: \"Stay Awake\",\n        version: \"1.4.0\",\n        summary: \"Keeps your Mac awake for a while, until a time, while an app runs or on a schedule.\",\n        surfaces: [.menuBar, .panel, .widget, .settings],\n        triggers: [.launch, .every(.seconds(15)), .wake],\n        capabilities: [.power],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/System/StayAwake\",\n        accent: .amber,\n        symbol: \"cup.and.saucer.fill\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<StayAwake>] = [\n        PreviewScene(\"hero\", \"Awake for the next hour, then back to normal\") { plugin in\n            let now = Date()\n            plugin.session = Session(mode: .until(now.addingTimeInterval(75 * 60)), started: now.addingTimeInterval(-45 * 60))\n            plugin.awakeByDay = sampleWeek(now: now)\n            plugin.batteryTrail = [0.92, 0.91, 0.9, 0.88, 0.87, 0.86, 0.85, 0.83, 0.82, 0.81]\n            try await plugin.refresh()\n        },\n        PreviewScene(\"while-app\", \"Awake while your build runs\") { plugin in\n            let now = Date()\n            plugin.session = Session(mode: .whileRunning(appID: \"com.apple.dt.Xcode\", name: \"Xcode\"), started: now.addingTimeInterval(-20 * 60))\n            plugin.awakeByDay = sampleWeek(now: now)\n            try await plugin.refresh()\n        },\n        PreviewScene(\"widget\", \"Your Mac's sleep state on the desktop\", surface: .widget) { plugin in\n            let now = Date()\n            plugin.session = Session(mode: .until(now.addingTimeInterval(75 * 60)), started: now.addingTimeInterval(-45 * 60))\n            plugin.awakeByDay = sampleWeek(now: now)\n            try await plugin.refresh()\n        },\n    ]\n\n    /// Seconds kept awake on each of the last seven days.\n    static func sampleWeek(now: Date) -> [String: Double] {\n        let hours = [2.5, 4, 1.5, 6, 3, 0.5, 2]\n        return Dictionary(uniqueKeysWithValues: hours.enumerated().map { offset, value in\n            (day(now.addingTimeInterval(Double(offset - 6) * 86400)), value * 3600)\n        })\n    }\n\n    /// What the current session waits for before it ends.\n    enum Mode: Codable, Equatable {\n        case indefinitely\n        case until(Date)\n        case whileRunning(appID: String, name: String)\n    }\n\n    struct Session: Codable, Equatable {\n        var mode: Mode\n        var started: Date\n        var total: Double? {\n            if case .until(let end) = mode { return end.timeIntervalSince(started) }\n            return nil\n        }\n    }\n\n    static let durations: [(title: String, minutes: Double)] = [(\"15 min\", 15), (\"30 min\", 30), (\"1 hour\", 60), (\"2 hours\", 120), (\"4 hours\", 240), (\"8 hours\", 480)]\n\n    @Stored(\"session\") var session: Session? = nil\n    @Stored(\"keepDisplayOn\") var keepDisplayOn = true\n    @Stored(\"batteryFloor\") var batteryFloor = 20.0\n    @Stored(\"pauseOnLowPower\") var pauseOnLowPower = true\n    @Stored(\"notifyOnEnd\") var notifyOnEnd = true\n    @Stored(\"scheduleOn\") var scheduleOn = false\n    @Stored(\"whilePluggedIn\") var whilePluggedIn = false\n    @Stored(\"whileExternalDisplay\") var whileExternalDisplay = false\n    @Stored(\"whileVPN\") var whileVPN = false\n    @Stored(\"whileDownloading\") var whileDownloading = false\n    /// What a click on the menu-bar cup does: -1 opens the panel, 0 starts\n    /// until stopped, otherwise starts for that many minutes. Clicking again\n    /// stops; a right click always opens the panel.\n    @Stored(\"clickMinutes\") var clickMinutes = 0.0\n    /// Shows the time left as digits next to the cup during a timed session.\n    @Stored(\"showTimeLeft\") var showTimeLeft = true\n    @Stored(\"scheduleStart\") var scheduleStart = \"09:00\"\n    @Stored(\"scheduleEnd\") var scheduleEnd = \"18:00\"\n    @Stored(\"scheduleWeekdaysOnly\") var scheduleWeekdaysOnly = true\n    /// Seconds awake per day (yyyy-MM-dd), for the last two weeks.\n    @Stored(\"awakeByDay\") var awakeByDay: [String: Double] = [:]\n    /// The automatic rules (plugged in, schedule and so on) hold off until then.\n    @Stored(\"pausedUntil\") var pausedUntil: Date? = nil\n\n    var status = SystemStatus()\n    /// What the host is holding for us right now. The host drops it when the\n    /// helper restarts, so this starts empty and the first refresh re-asserts.\n    var held: KeepAwakeRequest?\n    var heldReason: String?\n    var lastTick: Date?\n    var lastError: String?\n    /// A duration or time typed in the panel that didn't parse, shown under the field.\n    var inputError: String?\n    /// A schedule time typed in Settings that didn't parse, shown there.\n    var settingsError: String?\n    var lastEnded: String?\n    /// The end time the 5-minute warning was sent for.\n    var warnedFor: Date?\n    /// Battery percentage once a minute while awake, for the session chart.\n    var batteryTrail: [Double] = []\n    var lastSample: Date?\n    /// The last network counter reading, for the download rate.\n    var lastBytes: (count: UInt64, at: Date)?\n    /// Bytes per second received, one sample per refresh (about 10 minutes).\n    var rateTrail: [Double] = []\n    /// A download keeps the Mac awake until this long after it goes quiet,\n    /// so a pause between files doesn't let it sleep.\n    var downloadUntil: Date?\n    static let downloadThreshold = 100_000.0\n    static let downloadGrace = 120.0\n\n    // MARK: Deciding\n\n    func refresh() async throws {\n        do {\n            status = try await Stackbar.systemStatus()\n        } catch {\n            lastError = \"\\(error)\"\n        }\n        endSessionIfDone()\n        sampleNetwork()\n        await apply()\n    }\n\n    /// Turns the host's byte counter into a rate and decides whether a\n    /// download is running.\n    func sampleNetwork(now: Date = Date()) {\n        guard let count = status.networkBytesReceived else { return }\n        defer { lastBytes = (count, now) }\n        guard let last = lastBytes, count >= last.count else { return }\n        let seconds = now.timeIntervalSince(last.at)\n        guard seconds >= 1 else { return }\n        let rate = Double(count - last.count) / seconds\n        rateTrail.append(rate)\n        if rateTrail.count > 40 { rateTrail.removeFirst(rateTrail.count - 40) }\n        if rate >= Self.downloadThreshold { downloadUntil = now.addingTimeInterval(Self.downloadGrace) }\n    }\n\n    var downloading: Bool { (downloadUntil ?? .distantPast) > Date() }\n\n    static func rate(_ bytes: Double) -> String {\n        if bytes >= 1_000_000 { return String(format: \"%.1f MB/s\", bytes / 1_000_000) }\n        return \"\\(Int((bytes / 1000).rounded())) KB/s\"\n    }\n\n    /// Why the Mac should stay awake right now, or `nil` to let it sleep.\n    var reason: String? {\n        if blocker != nil { return nil }\n        if let session { return describe(session.mode) }\n        if let pausedUntil, pausedUntil > Date() { return nil }\n        if scheduleOn, inSchedule(Date()) { return \"Schedule until \\(scheduleEnd)\" }\n        if whilePluggedIn, status.onACPower { return \"While plugged in\" }\n        if whileExternalDisplay, (status.externalDisplays ?? 0) > 0 { return \"While a display is connected\" }\n        if whileVPN, status.vpnActive == true { return \"While the VPN is on\" }\n        if whileDownloading, downloading { return \"While a download is running\" }\n        return nil\n    }\n\n    /// Battery or Low Power Mode holding things back.\n    var blocker: String? {\n        if !status.onACPower, let level = status.batteryLevel, batteryFloor > 0, level * 100 < batteryFloor {\n            return \"Battery below \\(Int(batteryFloor))%\"\n        }\n        if pauseOnLowPower, status.lowPowerMode { return \"Low Power Mode is on\" }\n        return nil\n    }\n\n    func endSessionIfDone(now: Date = Date()) {\n        guard let session else { return }\n        switch session.mode {\n        case .indefinitely:\n            return\n        case .until(let end):\n            if now >= end {\n                finish(\"Time's up\")\n            } else if notifyOnEnd, end.timeIntervalSince(now) <= 5 * 60, warnedFor != end, session.total ?? 0 > 10 * 60 {\n                warnedFor = end\n                Stackbar.notify(\"Your Mac can sleep in 5 minutes\", body: \"Open Stay Awake to add more time.\")\n            }\n        case .whileRunning(let appID, let name):\n            if !status.runningApps.isEmpty, !status.runningApps.contains(where: { $0.id == appID }) { finish(\"\\(name) quit\") }\n        }\n    }\n\n    func finish(_ why: String) {\n        session = nil\n        lastEnded = why\n        if notifyOnEnd { Stackbar.notify(\"Your Mac can sleep again\", body: why) }\n    }\n\n    /// Brings the host's assertion in line with `reason`, and counts awake time.\n    func apply(now: Date = Date()) async {\n        if let lastTick, held != nil {\n            let day = Self.day(now)\n            awakeByDay[day, default: 0] += min(now.timeIntervalSince(lastTick), 60)\n            if awakeByDay.count > 14 { awakeByDay = awakeByDay.filter { $0.key >= Self.day(now.addingTimeInterval(-13 * 86400)) } }\n        }\n        lastTick = now\n        if held != nil, let level = status.batteryLevel, now.timeIntervalSince(lastSample ?? .distantPast) >= 60 {\n            batteryTrail.append((level * 1000).rounded() / 10)\n            if batteryTrail.count > 240 { batteryTrail.removeFirst(batteryTrail.count - 240) }\n            lastSample = now\n        }\n        let wanted = reason.map { KeepAwakeRequest(display: keepDisplayOn, reason: $0) }\n        guard wanted != held else { return }\n        do {\n            if let wanted {\n                try await Stackbar.keepAwake(display: wanted.display, reason: wanted.reason)\n            } else {\n                try await Stackbar.allowSleep()\n            }\n            held = wanted\n            lastError = nil\n        } catch {\n            lastError = \"\\(error)\"\n        }\n    }\n\n    func start(_ mode: Mode) async {\n        if session == nil { batteryTrail = [] }\n        session = Session(mode: mode, started: Date())\n        lastEnded = nil\n        await apply()\n    }\n\n    func stop() async {\n        session = nil\n        lastEnded = nil\n        await apply()\n    }\n\n    // MARK: Time helpers\n\n    func describe(_ mode: Mode) -> String {\n        switch mode {\n        case .indefinitely: return \"Until you stop it\"\n        case .until(let end): return \"Until \\(end.formatted(date: .omitted, time: .shortened))\"\n        case .whileRunning(_, let name): return \"While \\(name) is running\"\n        }\n    }\n\n    static func day(_ date: Date) -> String {\n        let parts = Calendar.current.dateComponents([.year, .month, .day], from: date)\n        return String(format: \"%04d-%02d-%02d\", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)\n    }\n\n    /// `\"17:30\"`, `\"9\"` or `\"5:30pm\"` as minutes after midnight.\n    static func minutes(_ text: String) -> Int? {\n        var text = text.lowercased().replacingOccurrences(of: \" \", with: \"\")\n        var meridiem: String?\n        for suffix in [\"am\", \"pm\"] where text.hasSuffix(suffix) {\n            meridiem = suffix\n            text.removeLast(2)\n        }\n        let parts = text.split(separator: \":\", omittingEmptySubsequences: false)\n        guard (1...2).contains(parts.count), let hour = Int(parts[0]) else { return nil }\n        guard let minute = parts.count == 2 ? Int(parts[1]) : 0, (0...59).contains(minute) else { return nil }\n        if let meridiem {\n            guard (1...12).contains(hour) else { return nil }\n            return (hour % 12 + (meridiem == \"pm\" ? 12 : 0)) * 60 + minute\n        }\n        guard (0...23).contains(hour) else { return nil }\n        return hour * 60 + minute\n    }\n\n    /// `\"90\"`, `\"90m\"`, `\"2h\"`, `\"1h30\"` or `\"1:30\"` as seconds.\n    static func duration(_ text: String) -> Double? {\n        let text = text.lowercased().replacingOccurrences(of: \" \", with: \"\").replacingOccurrences(of: \"min\", with: \"m\")\n        if let minutes = Double(text.hasSuffix(\"m\") ? String(text.dropLast()) : text), minutes > 0 { return minutes * 60 }\n        let parts = text.split(whereSeparator: { $0 == \"h\" || $0 == \":\" }).map(String.init)\n        guard text.contains(\"h\") || text.contains(\":\"), let hours = parts.first.flatMap(Double.init) else { return nil }\n        let minutes = parts.count > 1 ? Double(parts[1].replacingOccurrences(of: \"m\", with: \"\")) : 0\n        guard let minutes, hours * 60 + minutes > 0 else { return nil }\n        return (hours * 60 + minutes) * 60\n    }\n\n    /// The next time `text` (a clock time) happens, today or tomorrow.\n    static func next(_ text: String, after now: Date = Date()) -> Date? {\n        guard let minutes = minutes(text) else { return nil }\n        let today = Calendar.current.startOfDay(for: now).addingTimeInterval(Double(minutes) * 60)\n        return today > now ? today : Calendar.current.date(byAdding: .day, value: 1, to: today)\n    }\n\n    func inSchedule(_ date: Date) -> Bool {\n        guard let start = Self.minutes(scheduleStart), let end = Self.minutes(scheduleEnd) else { return false }\n        let calendar = Calendar.current\n        if scheduleWeekdaysOnly, calendar.isDateInWeekend(date) { return false }\n        let parts = calendar.dateComponents([.hour, .minute], from: date)\n        let now = (parts.hour ?? 0) * 60 + (parts.minute ?? 0)\n        return start <= end ? (start..<end).contains(now) : (now >= start || now < end)\n    }\n\n    static func clock(_ seconds: Double) -> String {\n        let total = max(0, Int(seconds.rounded(.up)))\n        let hours = total / 3600\n        let minutes = (total % 3600) / 60\n        if hours > 0 { return \"\\(hours)h \\(minutes)m\" }\n        if minutes > 0 { return \"\\(minutes)m\" }\n        return \"\\(total)s\"\n    }\n\n    var remaining: Double? {\n        if case .until(let end)? = session?.mode { return end.timeIntervalSinceNow }\n        return nil\n    }\n\n    // MARK: Surfaces\n\n    static let clickChoices: [(title: String, minutes: Double)] = [(\"Opens the panel\", -1), (\"Starts until I stop it\", 0)] + durations.map { (\"Starts for \\($0.title)\", $0.minutes) }\n\n    var menuBarClick: MenuBarAction? {\n        guard clickMinutes >= 0 else { return nil }\n        if session != nil {\n            return MenuBarAction(\"Click to stop keeping awake\") { await self.stop() }\n        }\n        let minutes = clickMinutes\n        let label = minutes == 0 ? \"until you stop it\" : \"for \\(Self.durations.first { $0.minutes == minutes }?.title ?? \"\\(Int(minutes)) min\")\"\n        return MenuBarAction(\"Click to keep awake \\(label)\") {\n            await self.start(minutes == 0 ? .indefinitely : .until(Date().addingTimeInterval(minutes * 60)))\n        }\n    }\n\n    let theme = Theme.amber\n\n    /// The timed session's span, for host-counted rings and clocks.\n    var range: TimeRange? {\n        guard let session, case .until(let end) = session.mode else { return nil }\n        return TimeRange(start: session.started, end: end)\n    }\n\n    /// Held by a rule rather than a session the user started.\n    var heldByRule: Bool { held != nil && session == nil }\n\n    var menuBar: ViewContent {\n        if held != nil, let range {\n            HStack(spacing: Space.xs) {\n                Image(systemName: \"cup.and.heat.waves.fill\")\n                if showTimeLeft {\n                    TimerText(range)\n                } else {\n                    ProgressBar(timing: range, style: .ring(lineWidth: 2)).frame(width: 13, height: 13)\n                }\n            }\n        } else if held != nil {\n            Label(\"\", symbol: \"cup.and.heat.waves.fill\").role(.primary)\n        } else if session != nil, blocker != nil {\n            Label(\"\", symbol: \"cup.and.saucer\").role(.warning)\n        } else {\n            Label(\"\", symbol: \"cup.and.saucer\").role(.secondary)\n        }\n    }\n\n    /// The cup in a ring that drains as a timed session runs out.\n    /// The cup in a ring of time left. Awake, it sits on the vivid hero in\n    /// `onSolid`, glowing and breathing; asleep, a quiet moon on neutral.\n    @ViewBuilder func dial(size: Double, lineWidth: Double) -> ViewContent {\n        if held != nil {\n            let ink = theme.onSolidPaint\n            ZStack {\n                Circle().fill(.radial([\"#FFFFFF59\", \"#FFFFFF00\"], radius: size * 0.62))\n                Circle().stroke(.hex(\"#FFFFFF3D\", dark: \"#00000029\"), lineWidth: lineWidth)\n                if let range {\n                    ProgressBar(timing: range, style: .ring(lineWidth: lineWidth)).foreground(ink)\n                } else {\n                    ProgressBar(value: 1, style: .ring(lineWidth: lineWidth)).foreground(ink)\n                }\n                Image(systemName: \"cup.and.heat.waves.fill\").font(size: (size * 0.36).rounded(), weight: .semibold).foreground(ink).symbolEffect(.breathe)\n            }\n            .frame(width: size, height: size)\n        } else {\n            Gauge(value: 0, theme: .neutral, size: size, lineWidth: lineWidth) {\n                Image(systemName: \"moon.zzz.fill\").font(size: (size * 0.36).rounded(), weight: .semibold).foreground(Theme.neutral.textPaint)\n            }\n        }\n    }\n\n    /// A button on the vivid hero: `onSolid` on a light veil.\n    func heroButton(_ title: String?, symbol: String, label: String? = nil, key: String? = nil, action: @escaping @MainActor () async throws -> Void) -> some StackbarView {\n        Button(key: key ?? title ?? label, action: action) {\n            HStack(spacing: Space.xs) {\n                Image(systemName: symbol).font(size: 12, weight: .semibold)\n                if let title { Text(title).typography(.headline) }\n            }\n            .foreground(theme.onSolidPaint)\n            .padding(horizontal: title == nil ? Space.s : Space.m, vertical: Space.s - 1)\n            .background(.hex(\"#00000029\", dark: \"#0000001F\"), in: .capsule)\n            .hover(scale: 1.04, lift: false)\n        }\n        .accessibilityLabel(label ?? title ?? symbol)\n    }\n\n    var caption: String {\n        if let reason, held != nil { return reason + (keepDisplayOn ? \" · display on\" : \" · display may sleep\") }\n        if let blocker, session != nil { return \"Paused: \\(blocker)\" }\n        if let pausedUntil, pausedUntil > Date() { return \"Rules paused until \\(pausedUntil.formatted(date: .omitted, time: .shortened))\" }\n        if let lastEnded { return \"Last session: \\(lastEnded)\" }\n        return \"Pick how long below\"\n    }\n\n    /// The chip that matches the running session, if any.\n    var activeChip: Double? {\n        guard let session else { return nil }\n        switch session.mode {\n        case .indefinitely: return 0\n        case .until: return session.total.map { ($0 / 60).rounded() }\n        case .whileRunning: return nil\n        }\n    }\n\n    static let chips: [[(title: String, minutes: Double)]] = [[(\"∞\", 0), (\"15m\", 15), (\"30m\", 30), (\"1h\", 60)], [(\"2h\", 120), (\"4h\", 240), (\"8h\", 480)]]\n\n    var panel: ViewContent {\n        let awake = held != nil\n        if awake {\n            HeroCard(theme: theme, look: .vivid) {\n                HStack(spacing: Space.m) {\n                    dial(size: 76, lineWidth: 6)\n                    VStack(spacing: Space.xxs) {\n                        Text(\"Awake\").typography(Typography(size: 22, weight: .bold, design: .rounded))\n                        Text(caption).typography(.caption).opacity(0.85)\n                    }\n                    .frame(fillWidth: true, alignment: .leading)\n                    heroButton(nil, symbol: keepDisplayOn ? \"display\" : \"display.trianglebadge.exclamationmark\", label: keepDisplayOn ? \"Display stays on\" : \"Keep display on\", key: \"display\") {\n                        self.keepDisplayOn.toggle()\n                        await self.apply()\n                    }\n                }\n                if let range {\n                    TimerText(range).typography(Typography(size: 52, weight: .heavy, design: .rounded, monospacedDigit: true))\n                }\n                if let blocker, session != nil {\n                    Text(\"Paused: \\(blocker)\").typography(.caption)\n                }\n                HStack(spacing: Space.s) {\n                    if session != nil {\n                        heroButton(\"Stop\", symbol: \"stop.fill\") { await self.stop() }\n                    }\n                    if let remaining {\n                        heroButton(\"+30 min\", symbol: \"plus\") { await self.start(.until(Date().addingTimeInterval(remaining + 1800))) }\n                    }\n                    if heldByRule {\n                        heroButton(\"Pause for 1 hour\", symbol: \"pause.fill\") {\n                            self.pausedUntil = Date().addingTimeInterval(3600)\n                            await self.apply()\n                        }\n                    }\n                    Spacer()\n                }\n            }\n        } else {\n            HeroCard(theme: .neutral) {\n                HStack(spacing: Space.m) {\n                    dial(size: 64, lineWidth: 5)\n                    VStack(spacing: Space.xxs) {\n                        Text(\"Sleeps normally\").typography(Typography(size: 22, weight: .bold, design: .rounded))\n                        Text(caption).role(.secondary).typography(.caption)\n                    }\n                    .frame(fillWidth: true, alignment: .leading)\n                    IconButton(\"display\", label: keepDisplayOn ? \"Display stays on\" : \"Keep display on\", theme: theme,\n                               prominence: keepDisplayOn ? .filled : .tinted, key: \"display\") {\n                        self.keepDisplayOn.toggle()\n                        await self.apply()\n                    }\n                }\n                if let blocker, session != nil {\n                    Banner(\"Paused: \\(blocker)\")\n                }\n                if session != nil {\n                    HStack(spacing: Space.s) {\n                        ActionButton(\"Stop\", symbol: \"stop.fill\", theme: .critical, prominent: true) { await self.stop() }\n                        Spacer()\n                    }\n                }\n            }\n        }\n        if let lastError {\n            Banner(lastError)\n        }\n        Card(\"Keep awake\", spacing: Space.s) {\n            ForEach(Array(Self.chips.enumerated())) { _, row in\n                HStack(spacing: Space.xs + 2) {\n                    ForEach(row) { option in\n                        Chip(option.title, theme: theme, selected: activeChip == option.minutes, key: \"for-\\(Int(option.minutes))\") {\n                            await self.start(option.minutes == 0 ? .indefinitely : .until(Date().addingTimeInterval(option.minutes * 60)))\n                        }\n                    }\n                    Spacer()\n                }\n            }\n            TextField(\"Or type 90m, 1h30 or 17:30\") { text in\n                if let seconds = Self.duration(text) {\n                    self.inputError = nil\n                    await self.start(.until(Date().addingTimeInterval(seconds)))\n                } else if let end = Self.next(text) {\n                    self.inputError = nil\n                    await self.start(.until(end))\n                } else {\n                    self.inputError = \"\\\"\\(text)\\\" isn't a duration or a time; try 45m, 1h30 or 17:30\"\n                }\n            }\n            if let inputError {\n                Banner(inputError)\n            }\n            let apps = status.runningApps.filter { $0.id != \"io.navbytes.stackbar\" }.sorted { $0.name < $1.name }\n            if !apps.isEmpty {\n                Picker(\"While an app runs\", options: apps.map(\\.name), selection: whileRunningName) { name in\n                    guard let app = apps.first(where: { $0.name == name }) else { return }\n                    await self.start(.whileRunning(appID: app.id, name: app.name))\n                }\n            }\n        }\n        let week = hoursAwake(days: 7)\n        if awakeByDay.values.contains(where: { $0 > 0 }) {\n            Card(\"This week\", detail: \"today \\(Self.clock(awakeByDay[Self.day(Date())] ?? 0)) · average \\(Self.clock(week.values.reduce(0, +) / 7 * 3600))\", spacing: Space.s) {\n                Chart(values: week.values, kind: .bar, labels: week.labels, unit: \"h\", highlight: 6).foreground(theme.solidPaint)\n            }\n        }\n        if awake, whileDownloading, rateTrail.count >= 3, rateTrail.contains(where: { $0 >= Self.downloadThreshold }) {\n            Card(\"Download speed\", detail: downloading ? Self.rate(rateTrail.last ?? 0) : \"quiet\", spacing: Space.s) {\n                Chart(values: rateTrail.map { ($0 / 10_000).rounded() / 100 }, kind: .area, unit: \"MB/s\", goal: Self.downloadThreshold / 1_000_000)\n                    .foreground((downloading ? theme : .neutral).solidPaint)\n                Text(downloading ? \"Staying awake until it's quiet for 2 minutes\" : \"No download running\").role(.secondary).typography(.caption)\n            }\n        }\n        if awake, !status.onACPower, let level = status.batteryLevel, batteryTrail.count >= 3 {\n            Card(\"Battery while awake\", detail: \"\\(Int((level * 100).rounded()))%\", spacing: Space.s) {\n                Chart(values: batteryTrail, kind: .area, unit: \"%\").foreground((level < 0.2 ? Theme.critical : .success).solidPaint)\n            }\n        }\n    }\n\n    /// Hours awake per day for the last `days` days, oldest first, with\n    /// short weekday names (or day numbers past a week).\n    func hoursAwake(days: Int, now: Date = Date()) -> (values: [Double], labels: [String]) {\n        let calendar = Calendar.current\n        let dates = (0..<days).reversed().map { now.addingTimeInterval(Double(-$0) * 86400) }\n        let values = dates.map { ((awakeByDay[Self.day($0)] ?? 0) / 360).rounded() / 10 }\n        let labels = dates.map { date in\n            days <= 7 ? calendar.shortWeekdaySymbols[calendar.component(.weekday, from: date) - 1] : String(calendar.component(.day, from: date))\n        }\n        return (values, labels)\n    }\n\n    /// Stackbar's Settings window, so the panel stays about starting and stopping.\n    var settings: ViewContent {\n        Section(\"Menu bar\") {\n            Picker(\"Clicking the cup\", options: Self.clickChoices.map(\\.title), selection: Self.clickChoices.first { $0.minutes == clickMinutes }?.title ?? Self.clickChoices[1].title, key: \"click\") { title in\n                self.clickMinutes = Self.clickChoices.first { $0.title == title }?.minutes ?? 0\n            }\n            Toggle(\"Show time left during a timed session\", isOn: showTimeLeft, key: \"time-left\") { self.showTimeLeft = $0 }\n            Text(\"A right click always opens the panel. Toggle, Keep Awake For and Allow Sleep are commands too, so you can give them a hotkey or run them from Shortcuts.\").style(.caption).role(.secondary)\n        }\n        Section(\"Stop or pause\") {\n            Slider(batteryFloor > 0 ? \"Stop below \\(Int(batteryFloor))% battery\" : \"Ignore the battery level\", value: batteryFloor, in: 0...50, step: 5, key: \"battery\") { value in\n                self.batteryFloor = value\n                await self.apply()\n            }\n            Toggle(\"Pause in Low Power Mode\", isOn: pauseOnLowPower) { on in\n                self.pauseOnLowPower = on\n                await self.apply()\n            }\n            Toggle(\"Notify 5 minutes before and when a session ends\", isOn: notifyOnEnd, key: \"notify\") { self.notifyOnEnd = $0 }\n        }\n        Section(\"Automatically\") {\n            Toggle(\"Always stay awake while plugged in\", isOn: whilePluggedIn) { on in\n                self.whilePluggedIn = on\n                await self.apply()\n            }\n            Toggle(\"While an external display is connected\", isOn: whileExternalDisplay, key: \"display\") { on in\n                self.whileExternalDisplay = on\n                await self.apply()\n            }\n            Toggle(\"While a VPN is connected\", isOn: whileVPN, key: \"vpn\") { on in\n                self.whileVPN = on\n                await self.apply()\n            }\n            Toggle(\"While a download is running\", isOn: whileDownloading, key: \"download\") { on in\n                self.whileDownloading = on\n                await self.apply()\n            }\n            Text(\"A download is anything over \\(Self.rate(Self.downloadThreshold)); the Mac stays awake until it's been quiet for 2 minutes.\").style(.caption).role(.secondary)\n            Text(\"To work with the lid closed, connect power and a display (macOS calls it clamshell mode), and turn on the display option above so it doesn't idle-sleep.\").style(.caption).role(.secondary)\n        }\n        Section(\"Schedule\") {\n            Toggle(scheduleWeekdaysOnly ? \"Weekdays \\(scheduleStart)–\\(scheduleEnd)\" : \"Every day \\(scheduleStart)–\\(scheduleEnd)\", isOn: scheduleOn, key: \"schedule\") { on in\n                self.scheduleOn = on\n                await self.apply()\n            }\n            TextField(\"Starts, like 09:00\", text: scheduleStart, key: \"schedule-start\") { text in await self.setSchedule(start: text) }\n            TextField(\"Ends, like 18:00\", text: scheduleEnd, key: \"schedule-end\") { text in await self.setSchedule(end: text) }\n            if let settingsError { Text(settingsError).style(.caption).role(.warning) }\n            Toggle(\"Weekdays only\", isOn: scheduleWeekdaysOnly) { on in\n                self.scheduleWeekdaysOnly = on\n                await self.apply()\n            }\n        }\n    }\n\n    var widget: ViewContent {\n        if held != nil {\n            HeroCard(theme: theme, look: .vivid, spacing: Space.s) {\n                HStack(spacing: Space.m) {\n                    dial(size: 64, lineWidth: 5)\n                    VStack(spacing: Space.xxs) {\n                        Text(\"Awake\").typography(.title)\n                        if let range { TimerText(range).typography(Typography(size: 26, weight: .heavy, design: .rounded, monospacedDigit: true)) }\n                        Text(reason ?? \"\").typography(.caption).opacity(0.85)\n                    }\n                    .frame(fillWidth: true, alignment: .leading)\n                }\n            }\n            .frame(fillWidth: true, fillHeight: true)\n        } else {\n            HStack(spacing: Space.m) {\n                dial(size: 64, lineWidth: 5)\n                VStack(spacing: Space.xxs) {\n                    Text(\"Sleeps normally\").typography(.title)\n                    Text(\"Today \\(Self.clock(awakeByDay[Self.day(Date())] ?? 0)) awake\").role(.secondary).typography(.caption)\n                }\n                .frame(fillWidth: true, alignment: .leading)\n            }\n            .frame(fillWidth: true, fillHeight: true)\n        }\n    }\n\n    var whileRunningName: String? {\n        if case .whileRunning(_, let name)? = session?.mode { return name }\n        return nil\n    }\n\n    func setSchedule(start: String? = nil, end: String? = nil) async {\n        for text in [start, end].compactMap({ $0 }) where Self.minutes(text) == nil {\n            settingsError = \"\\\"\\(text)\\\" isn't a time; try 09:00 or 6pm\"\n            return\n        }\n        if let start { scheduleStart = start.trimmingCharacters(in: .whitespaces) }\n        if let end { scheduleEnd = end.trimmingCharacters(in: .whitespaces) }\n        settingsError = nil\n        await apply()\n    }\n\n    // MARK: Commands\n\n    /// What a typed `for` argument means: nothing is until stopped, `90m`,\n    /// `2h` or `1:30` is a duration, and `until 17:30` or `5pm` is a time.\n    static func mode(for text: String?, now: Date = Date()) -> Mode? {\n        let text = (text ?? \"\").trimmingCharacters(in: .whitespaces).lowercased()\n        if text.isEmpty { return .indefinitely }\n        if text.hasPrefix(\"until \") { return next(String(text.dropFirst(6)), after: now).map(Mode.until) }\n        if let seconds = duration(text) { return .until(now.addingTimeInterval(seconds)) }\n        return next(text, after: now).map(Mode.until)\n    }\n\n    var commands: [Command] {\n        Command(\"toggle\", title: \"Toggle Stay Awake\", symbol: \"cup.and.heat.waves\", keywords: [\"awake\", \"caffeine\", \"amphetamine\", \"sleep\"]) { _ in\n            if self.session != nil {\n                await self.stop()\n                return .message(\"Your Mac can sleep again\")\n            }\n            await self.start(.indefinitely)\n            return .message(\"Keeping your Mac awake until you stop it\")\n        }\n        Command(\"keep-awake\", title: \"Keep Awake For…\", subtitle: \"A duration like 90m or 2h, or a time like 17:30\", symbol: \"timer\", keywords: [\"awake\", \"caffeine\", \"sleep\", \"until\"]) { invocation in\n            guard let mode = Self.mode(for: invocation[\"for\"]) else {\n                return .failure(\"Try a duration like 90m or 2h, or a time like 17:30\")\n            }\n            await self.start(mode)\n            return .message(\"Keeping your Mac awake \" + self.describe(mode).lowercased())\n        }\n        .argument(\"for\", placeholder: \"2h, 90m or 17:30\", required: false)\n        Command(\"allow-sleep\", title: \"Allow Sleep\", symbol: \"moon.zzz\", keywords: [\"awake\", \"sleep\", \"stop\"]) { _ in\n            await self.stop()\n            return .message(\"Your Mac can sleep again\")\n        }\n    }\n\n    // MARK: Agents\n\n    var tools: [AgentTool] {\n        AgentTool(\"awake_status\", description: \"Whether the Mac is being kept awake, why, and for how long\") { _ in\n            guard let reason = self.reason, self.held != nil else { return \"The Mac sleeps normally.\" + (self.blocker.map { \" (\\($0))\" } ?? \"\") }\n            return \"Staying awake: \\(reason)\" + (self.remaining.map { \", \\(Self.clock($0)) left\" } ?? \"\")\n        }\n        AgentTool(\"keep_awake\", description: \"Keep the Mac awake, for a number of minutes or until stopped\", parameters: [ToolParameter(\"minutes\", .number, description: \"Minutes; leave out to keep awake until stopped\", required: false)]) { arguments in\n            if let minutes = arguments.number(\"minutes\"), minutes > 0 {\n                await self.start(.until(Date().addingTimeInterval(minutes * 60)))\n                return \"Keeping the Mac awake for \\(Self.clock(minutes * 60)).\"\n            }\n            await self.start(.indefinitely)\n            return \"Keeping the Mac awake until stopped.\"\n        }\n        AgentTool(\"allow_sleep\", description: \"Stop keeping the Mac awake\") { _ in\n            await self.stop()\n            return \"The Mac can sleep again.\"\n        }\n    }\n}\n",
  "sourceDigest" : "45672cc700d08d04c41e62ba91eb55f3b6c9f1bef1fdd76875887a42e0fe9eb7"
}