Fix System Monitor metrics in extensions (#647)

* Fix Node CPU metrics for system monitor extensions

* Complete system monitor OS metrics and helper support

* Forward extension list selection changes

* Extract extension selection forwarding

* Seed extension selection from selectedItemId

---------

Co-authored-by: Jonas List <Jonas.List.1289@gmail.com>
This commit is contained in:
Micci
2026-09-14 05:04:56 +06:00
committed by GitHub
co-authored by Jonas List
parent 4e88bd742a
commit 740cbd8639
17 changed files with 334 additions and 35 deletions
+11
View File
@@ -184,11 +184,17 @@ export default function Command() {
return error.code ?? error.name;
}
};
const cpu = os.cpus()[0];
const parts = [
path.join("/a/b", "../c", "d.txt"),
path.extname("x/y/file.tar.gz"),
path.basename("/a/b/c.md", ".md"),
os.platform(),
Object.keys(cpu.times).sort().join(","),
String(Object.values(cpu.times).every(Number.isFinite)),
String(os.freemem() > 0),
String(os.uptime() > 0),
String(os.loadavg().length === 3 && os.loadavg().every(Number.isFinite)),
new URL("/next?q=1", "https://example.com/base/page").href,
new URLSearchParams({ a: "1", b: "two words" }).toString(),
crypto.createHash("sha256").update("abc").digest("hex").slice(0, 8),
@@ -639,6 +645,11 @@ export async function runFixtures() {
".gz",
"c",
"darwin",
"idle,irq,nice,sys,user",
"true",
"true",
"true",
"true",
"https://example.com/next?q=1",
"a=1&b=two+words",
"ba7816bf",
+4 -3
View File
@@ -230,10 +230,11 @@ const os = {
uid: 501,
gid: 20,
}),
cpus: () => Array.from({ length: bootEnvironment.cpus || 8 }, () => ({ model: "Apple Silicon", speed: 0, times: {} })),
cpus: () => hostCallSync("os", "cpus", []),
totalmem: () => bootEnvironment.totalmem || 0,
freemem: () => 0,
uptime: () => 0,
freemem: () => hostCallSync("os", "freemem", []),
uptime: () => hostCallSync("os", "uptime", []),
loadavg: () => hostCallSync("os", "loadavg", []),
networkInterfaces: () => ({}),
endianness: () => "LE",
devNull: "/dev/null",
+9 -2
View File
@@ -12,7 +12,7 @@ import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { createHash, randomUUID, randomBytes, createHmac } from "node:crypto";
import { homedir, tmpdir } from "node:os";
import { cpus, freemem, homedir, loadavg, tmpdir, uptime } from "node:os";
import * as fs from "node:fs";
import * as zlib from "node:zlib";
@@ -130,6 +130,14 @@ export function createHarness({ onRender, onFail, verbose = false, stubs = {} }
function syncHostCall(api, method, args) {
switch (`${api}.${method}`) {
case "os.cpus":
return cpus();
case "os.freemem":
return freemem();
case "os.uptime":
return uptime();
case "os.loadavg":
return loadavg();
case "fs.open":
return fs.openSync(args[0], args[1], args[2]);
case "fs.close":
@@ -303,7 +311,6 @@ export function bootConfig(overrides = {}) {
homedir: homedir(),
tmpdir: tmpdir(),
username: "tester",
cpus: 8,
},
environment: {
extensionName: "fixture",
+26
View File
@@ -145,6 +145,31 @@ struct ExtensionCleanupTests {
"a workspace the installer would make is one the sweep finds")
}
static func executableAssetsAreRestored() {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("ext-mode-test-\(UUID().uuidString)", isDirectory: true)
let assets = base.appendingPathComponent("assets", isDirectory: true)
try? FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: base) }
let binary = assets.appendingPathComponent("helper")
let script = assets.appendingPathComponent("script")
let text = assets.appendingPathComponent("readme")
try? Data([0xCF, 0xFA, 0xED, 0xFE]).write(to: binary)
try? Data("#!/bin/sh\nexit 0\n".utf8).write(to: script)
try? Data("ordinary asset".utf8).write(to: text)
for file in [binary, script, text] {
try? FileManager.default.setAttributes(
[.posixPermissions: 0o644], ofItemAtPath: file.path)
}
try? ExtensionCatalog.restoreExecutablePermissions(in: base)
expect(FileManager.default.isExecutableFile(atPath: binary.path), "a Mach-O helper is repaired")
expect(FileManager.default.isExecutableFile(atPath: script.path), "a script helper is repaired")
expect(!FileManager.default.isExecutableFile(atPath: text.path), "an ordinary asset stays data")
}
/// Raycast Beta keeps extensions under `raycast-x`; checking only `raycast` missed it.
static func bothRaycastChannelsAreSearched() {
let roots = ExtensionCatalog.raycastExtensionRoots().map(\.path)
@@ -165,6 +190,7 @@ struct ExtensionCleanupTests {
reclaimableMatchesClean()
emptyAndMissingRootsAreSafe()
workspaceIsSweptByItsOwnPrefix()
executableAssetsAreRestored()
print(failures == 0 ? "Extension cleanup tests passed" : "\(failures) tests failed")
exit(failures == 0 ? 0 : 1)
+8 -5
View File
@@ -140,20 +140,23 @@ struct ExtensionStoreTests {
static func gitHubTree() {
print("\n# github tree")
let payload = """
{"tree":[{"path":"src","type":"tree","sha":"t1"},
{"path":"package.json","type":"blob","sha":"b1"},
{"path":"src/index.ts","type":"blob","sha":"b2"}],"truncated":false}
{"tree":[{"path":"src","type":"tree","sha":"t1","mode":"040000"},
{"path":"package.json","type":"blob","sha":"b1","mode":"100644"},
{"path":"bin/helper","type":"blob","sha":"b2","mode":"100755"},
{"path":"src/index.ts","type":"blob","sha":"b3","mode":"100644"}],"truncated":false}
"""
guard let tree = try? ExtensionStoreResponse.parseTree(Data(payload.utf8)) else {
check("a tree parses", false)
return
}
check("every entry parses", tree.tree.count == 3)
check("every entry parses", tree.tree.count == 4)
check("a directory is flagged", tree.tree[0].isDirectory)
check("a file is flagged", tree.tree[1].isFile)
check("a directory is not a file", !tree.tree[0].isFile)
check("an executable blob keeps its mode", tree.tree[2].isExecutable)
check("an ordinary blob is not executable", !tree.tree[1].isExecutable)
// A recursive listing carries nested paths, which is what makes one call enough.
check("nested paths survive", tree.tree[2].path == "src/index.ts")
check("nested paths survive", tree.tree[3].path == "src/index.ts")
check("a directory is found by name", tree.directorySHA(named: "src") == "t1")
check("only directories are named", tree.directoryNames == ["src"])
+80 -5
View File
@@ -187,6 +187,7 @@ struct ExtensionTests {
screenChecks()
actionIconChecks()
oauthUnitChecks()
nodeShimChecks()
await runtimeChecks()
await searchAccessoryRuntimeChecks()
await nodeContractChecks()
@@ -196,6 +197,56 @@ struct ExtensionTests {
exit(failures == 0 ? 0 : 1)
}
static func nodeShimChecks() {
let result = ExtensionNodeShims().perform(api: "os", method: "cpus", argsJSON: "[]")
guard
let data = result.data(using: .utf8),
let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
envelope["ok"] as? Bool == true,
let processors = envelope["value"] as? [[String: Any]]
else {
check("os.cpus host call succeeds", false, result)
return
}
check(
"os.cpus returns every processor",
processors.count == ProcessInfo.processInfo.processorCount,
"\(processors.count)")
let expectedStates = Set(["user", "nice", "sys", "idle", "irq"])
let valid = processors.allSatisfy { processor in
guard
processor["model"] is String,
processor["speed"] is NSNumber,
let times = processor["times"] as? [String: NSNumber],
Set(times.keys) == expectedStates
else { return false }
return times.values.allSatisfy { $0.doubleValue.isFinite && $0.doubleValue >= 0 }
}
check("os.cpus returns finite Node timing fields", valid, result)
func value(_ method: String) -> Any? {
let result = ExtensionNodeShims().perform(api: "os", method: method, argsJSON: "[]")
guard let data = result.data(using: .utf8),
let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
envelope["ok"] as? Bool == true
else { return nil }
return envelope["value"]
}
let uptime = (value("uptime") as? NSNumber)?.doubleValue
check("os.uptime returns the system uptime", uptime.map { $0 > 0 } == true)
let freeMemory = (value("freemem") as? NSNumber)?.doubleValue
check(
"os.freemem returns finite bytes",
freeMemory.map { $0.isFinite && $0 >= 0 && $0 <= Double(ProcessInfo.processInfo.physicalMemory) }
== true)
let loadAverages = value("loadavg") as? [NSNumber]
check(
"os.loadavg returns three finite values",
loadAverages?.count == 3
&& loadAverages?.allSatisfy { $0.doubleValue.isFinite && $0.doubleValue >= 0 } == true)
}
static func manifestChecks() {
let json: [String: Any] = [
"name": "demo", "title": "Demo", "description": "d", "author": "a",
@@ -398,20 +449,27 @@ struct ExtensionTests {
}
let listJSON = """
{"id":2,"type":"List","props":{"filtering":true,"searchBarPlaceholder":"Find…"},"children":[
{"id":2,"type":"List","props":{"filtering":true,"selectedItemId":"banana","searchBarPlaceholder":"Find…",
"onSelectionChange":{"$fn":"2:onSelectionChange"}},"children":[
{"id":3,"type":"List.Section","props":{"title":"Alpha","subtitle":"two"},"children":[
{"id":4,"type":"List.Item","props":{"title":"Apple"},"children":[]},
{"id":5,"type":"List.Item","props":{"title":"Banana"},"children":[]}]},
{"id":6,"type":"List.Item","props":{"title":"Cherry","keywords":["red"]},"children":[]}]}
{"id":4,"type":"List.Item","props":{"id":"apple","title":"Apple"},"children":[]},
{"id":5,"type":"List.Item","props":{"id":"banana","title":"Banana"},"children":[]}]},
{"id":6,"type":"List.Item","props":{"id":"cherry","title":"Cherry","keywords":["red"]},"children":[]}]}
"""
let list = ExtensionScreen(tree: tree(listJSON), query: "")
check("kind is list", list.kind == .list)
check("placeholder", list.searchPlaceholder == "Find…")
check("filters locally", list.filtersLocally)
check("selected item id", list.selectedItemID == "banana")
check("selected item index", list.selectedItemIndex == 1)
check(
"items flattened in order",
list.items.map { $0.node.string("title") } == ["Apple", "Banana", "Cherry"])
check("rows interleave the section header", list.rows.count == 4, "\(list.rows.count)")
check(
"selection callback resolves the item id",
list.selectionChange(at: 1)
== .init(handler: "2:onSelectionChange", itemID: "banana"))
if case .header(let title, let subtitle, _) = list.rows.first {
check("header title", title == "Alpha")
check("header subtitle", subtitle == "two")
@@ -426,6 +484,15 @@ struct ExtensionTests {
filtered.items.map { $0.node.string("title") } == ["Banana"],
String(describing: filtered.items.map { $0.node.string("title") }))
check("empty section drops its header", filtered.rows.count == 2, "\(filtered.rows.count)")
check(
"filtered selection resolves after filtering",
filtered.selectionChange(at: 0)
== .init(handler: "2:onSelectionChange", itemID: "banana"))
check("filtered selected item index", filtered.selectedItemIndex == 0)
check(
"an empty selection reports null",
filtered.selectionChange(at: 1)
== .init(handler: "2:onSelectionChange", itemID: nil))
let byKeyword = ExtensionScreen(tree: tree(listJSON), query: "red")
check("keyword match", byKeyword.items.map { $0.node.string("title") } == ["Cherry"])
@@ -688,6 +755,7 @@ struct ExtensionTests {
const { List, ActionPanel, Action, Icon, showToast, Toast } = require("@raycast/api");
const React = require("react");
const path = require("node:path");
const os = require("node:os");
const crypto = require("node:crypto");
const { fileURLToPath, pathToFileURL } = require("node:url");
const util = require("node:util");
@@ -700,6 +768,8 @@ struct ExtensionTests {
return () => clearTimeout(timer);
}, []);
const digest = crypto.createHash("sha256").update("abc").digest("hex").slice(0, 8);
const cpu = os.cpus()[0];
const cpuTimes = Object.values(cpu.times).every(Number.isFinite) ? "cpu=ok" : "cpu=bad";
// AbortSignal's statics, the brand node-fetch checks, and url.parse's legacy `path`.
const abortable = [
typeof AbortSignal.timeout, typeof AbortSignal.abort, typeof AbortSignal.any,
@@ -735,7 +805,7 @@ struct ExtensionTests {
icon: Icon.Circle,
accessories: [
{ text: digest }, { text: abortable }, { text: filePaths },
{ text: utilShim },
{ text: cpuTimes }, { text: utilShim },
],
actions: h(ActionPanel, null,
h(Action, { title: "Bump", onAction: () => setCount((v) => v + 10) }))
@@ -768,6 +838,11 @@ struct ExtensionTests {
ExtensionAccessoriesView_labelForTest(screen.items.first?.node.array("accessories").first)
== "ba7816bf",
String(describing: screen.items.first?.node.array("accessories").first))
check(
"os.cpus crosses the synchronous host bridge",
ExtensionAccessoriesView_labelForTest(
screen.items.first?.node.array("accessories").dropFirst(3).first) == "cpu=ok",
String(describing: screen.items.first?.node.array("accessories")))
check("toast reached the host", host.toasts == ["hello"], host.toasts.joined(separator: ","))
check(
"AbortSignal survives node-fetch's brand checks, and url.parse keeps its path",
+5 -1
View File
@@ -359,6 +359,7 @@
9A80D2997DCF835AF225A586 /* RunningAppsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5FA545F484D7666E9A5F14 /* RunningAppsMonitor.swift */; };
9B05C47A7414D04E20237B09 /* RenderNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A1D354C1785923E612848C /* RenderNode.swift */; };
9B3BC1DCC478DA7517F04182 /* CalculatorHistoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86545F54669A075FBDA5D1F1 /* CalculatorHistoryScreen.swift */; };
9B8C1C35BA7DFC41F1A070CD /* ExtensionSelectionForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF4FF95DC8A5CD7676DCBA60 /* ExtensionSelectionForwarder.swift */; };
9BB417EBD7843DDE09AA0200 /* ChatCopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E67F6AC9A292F385FB5A5 /* ChatCopyButton.swift */; };
9BE84B47BCF0DA5552AB3591 /* CameraCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5873BB8728FD8BC5D256A21 /* CameraCoordinator.swift */; };
9DB09F96E6F8C226BD9AB1C9 /* SymbolCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387BE675F580496656E72559 /* SymbolCatalog.swift */; };
@@ -1185,6 +1186,7 @@
EE21578FB7500CB744855654 /* FileSearchFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchFilter.swift; sourceTree = "<group>"; };
EEAFF79E9897975CAC5EAC0E /* NoteEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteEditorView.swift; sourceTree = "<group>"; };
EF202236034AB044CA846B60 /* WindowLayoutsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLayoutsSection.swift; sourceTree = "<group>"; };
EF4FF95DC8A5CD7676DCBA60 /* ExtensionSelectionForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionSelectionForwarder.swift; sourceTree = "<group>"; };
EFF8EEC4B5B30644A7EAA117 /* InterfaceMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceMetrics.swift; sourceTree = "<group>"; };
F023729C84682E9231F3D73B /* ExtensionInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionInstaller.swift; sourceTree = "<group>"; };
F0311153A4A3557C6FFEAAAB /* HotKeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotKeyManager.swift; sourceTree = "<group>"; };
@@ -2336,6 +2338,7 @@
996CF61F3B13A573B8D5A11F /* ExtensionRefreshIndicator.swift */,
CCCBDD92DEAD780A1DC423CA /* ExtensionScreen.swift */,
5CB044816D6A7BC72E676A6C /* ExtensionSearchAccessoryButton.swift */,
EF4FF95DC8A5CD7676DCBA60 /* ExtensionSelectionForwarder.swift */,
A7751EBF0F632A123B4A2363 /* ExtensionTintColors.swift */,
);
path = UI;
@@ -2928,8 +2931,8 @@
projectDirPath = "";
projectRoot = "";
targets = (
51DBA6947AFD8275A0DF8DA0 /* Tinycast */,
0832DCD58DEA01602328CCE7 /* ClipboardTextHelper */,
51DBA6947AFD8275A0DF8DA0 /* Tinycast */,
);
};
/* End PBXProject section */
@@ -3196,6 +3199,7 @@
F51785CF5A6EC0B87D6802C9 /* ExtensionScreen.swift in Sources */,
CF59A1D1C7FE76558B638460 /* ExtensionSearchAccessory.swift in Sources */,
ECAE658F8DCCE74E49FDFFC2 /* ExtensionSearchAccessoryButton.swift in Sources */,
9B8C1C35BA7DFC41F1A070CD /* ExtensionSelectionForwarder.swift in Sources */,
606809BC14D6613B847036D0 /* ExtensionStorage.swift in Sources */,
9608AFDEDC3B330E64F53437 /* ExtensionStoreClient.swift in Sources */,
0120AF7AB3AD249C484C8A03 /* ExtensionStoreResponse.swift in Sources */,
@@ -10,7 +10,6 @@ struct ExtensionBootConfig: Sendable {
var homeDirectory: String
var temporaryDirectory: String
var workingDirectory: String
var cpuCount: Int
var totalMemory: Double
var environmentVariables: [String: String]
@@ -36,7 +35,6 @@ struct ExtensionBootConfig: Sendable {
homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path,
temporaryDirectory: FileManager.default.temporaryDirectory.path,
workingDirectory: supportDirectory.path,
cpuCount: info.processorCount,
totalMemory: Double(info.physicalMemory),
environmentVariables: variables)
}
@@ -53,7 +51,6 @@ struct ExtensionBootConfig: Sendable {
"homedir": homeDirectory,
"tmpdir": temporaryDirectory,
"cwd": workingDirectory,
"cpus": cpuCount,
"totalmem": totalMemory,
"env": environmentVariables,
"execPath": ""
@@ -100,9 +100,11 @@ enum ExtensionStoreResponse {
let path: String
let type: String
let sha: String
let mode: String?
var isDirectory: Bool { type == "tree" }
var isFile: Bool { type == "blob" }
var isExecutable: Bool { isFile && mode == "100755" }
}
func directorySHA(named name: String) -> String? {
@@ -118,6 +118,7 @@ enum ExtensionCatalog {
return
entries
.compactMap { directory -> InstalledExtension? in
try? restoreExecutablePermissions(in: directory)
guard let manifest = try? ExtensionManifest.load(directory: directory),
manifest.supportsMacOS
else { return nil }
@@ -126,6 +127,44 @@ enum ExtensionCatalog {
.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
}
/// GitHub's raw-file downloads lose mode bits, so restore runnable helper assets by content.
nonisolated static func restoreExecutablePermissions(in directory: URL) throws {
let fileManager = FileManager.default
let assets = directory.appendingPathComponent("assets", isDirectory: true)
guard
let enumerator = fileManager.enumerator(
at: assets,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles])
else { return }
while let file = enumerator.nextObject() as? URL {
let values = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
guard values.isRegularFile == true, values.isSymbolicLink != true,
!fileManager.isExecutableFile(atPath: file.path), isExecutablePayload(file)
else { continue }
let attributes = try fileManager.attributesOfItem(atPath: file.path)
let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue ?? 0o644
let executeBits = (permissions & 0o444) >> 2
try fileManager.setAttributes(
[.posixPermissions: permissions | executeBits], ofItemAtPath: file.path)
}
}
private nonisolated static func isExecutablePayload(_ file: URL) -> Bool {
guard let handle = try? FileHandle(forReadingFrom: file) else { return false }
defer { try? handle.close() }
guard let data = try? handle.read(upToCount: 4) else { return false }
let bytes = Array(data)
if bytes.starts(with: [0x23, 0x21]) { return true }
return [
[0xCA, 0xFE, 0xBA, 0xBE], [0xBE, 0xBA, 0xFE, 0xCA],
[0xCA, 0xFE, 0xBA, 0xBF], [0xBF, 0xBA, 0xFE, 0xCA],
[0xCE, 0xFA, 0xED, 0xFE], [0xCF, 0xFA, 0xED, 0xFE],
[0xFE, 0xED, 0xFA, 0xCE], [0xFE, 0xED, 0xFA, 0xCF]
].contains(bytes)
}
// MARK: - Install
enum InstallError: LocalizedError {
@@ -182,6 +221,7 @@ enum ExtensionCatalog {
if fm.fileExists(atPath: assets.path) {
try fm.copyItem(at: assets, to: destination.appendingPathComponent("assets"))
}
try restoreExecutablePermissions(in: destination)
} catch {
throw InstallError.copyFailed(error.localizedDescription)
}
@@ -51,6 +51,7 @@ final class ExtensionNodeShims: @unchecked Sendable {
private func dispatch(api: String, method: String, arguments: [Any]) throws -> Any? {
switch api {
case "fs": return try filesystem(method: method, arguments: arguments)
case "os": return try operatingSystem(method: method)
case "proc": return try process(method: method, arguments: arguments)
case "crypto": return try crypto(method: method, arguments: arguments)
case "zlib": return try compression(method: method, arguments: arguments)
@@ -58,6 +59,73 @@ final class ExtensionNodeShims: @unchecked Sendable {
}
}
// MARK: - os
private func operatingSystem(method: String) throws -> Any {
if method == "uptime" { return ProcessInfo.processInfo.systemUptime }
if method == "loadavg" {
var averages = [Double](repeating: 0, count: 3)
let count = averages.withUnsafeMutableBufferPointer {
getloadavg($0.baseAddress, Int32($0.count))
}
guard count == Int32(averages.count) else {
throw ShimError.failed("Could not read system load averages.")
}
return averages
}
if method == "freemem" {
var statistics = vm_statistics64_data_t()
var count = mach_msg_type_number_t(
MemoryLayout<vm_statistics64_data_t>.stride / MemoryLayout<integer_t>.stride)
let result = withUnsafeMutablePointer(to: &statistics) { pointer in
pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard result == KERN_SUCCESS else {
throw ShimError.failed("Could not read free memory (Mach error \(result)).")
}
return Double(statistics.free_count) * Double(getpagesize())
}
guard method == "cpus" else {
throw ShimError.failed("os.\(method) is not supported.", "ENOSYS")
}
var processorCount: natural_t = 0
var processorInfo: processor_info_array_t?
var processorInfoCount: mach_msg_type_number_t = 0
let result = host_processor_info(
mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &processorCount,
&processorInfo, &processorInfoCount)
guard result == KERN_SUCCESS, let processorInfo else {
throw ShimError.failed("Could not read CPU load (Mach error \(result)).")
}
defer {
_ = vm_deallocate(
mach_task_self_, vm_address_t(UInt(bitPattern: processorInfo)),
vm_size_t(processorInfoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
}
let millisecondsPerTick = 1_000 / Double(CLK_TCK)
return (0..<Int(processorCount)).map { processor -> [String: Any] in
let offset = processor * Int(CPU_STATE_MAX)
func milliseconds(_ state: Int32) -> Double {
Double(processorInfo[offset + Int(state)]) * millisecondsPerTick
}
return [
"model": "Apple Silicon",
"speed": 0,
"times": [
"user": milliseconds(CPU_STATE_USER),
"nice": milliseconds(CPU_STATE_NICE),
"sys": milliseconds(CPU_STATE_SYSTEM),
"idle": milliseconds(CPU_STATE_IDLE),
"irq": 0
]
]
}
}
// MARK: - fs
private func filesystem(method: String, arguments: [Any]) throws -> Any? {
@@ -173,6 +173,9 @@ struct ExtensionStoreClient: Sendable {
try fileManager.createDirectory(
at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
try await get(raw).write(to: target, options: .atomic)
if entry.isExecutable {
try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: target.path)
}
}
}
@@ -3,6 +3,11 @@ import SwiftUI
/// The one source of row order, so the palette's flat `selection` maps 1:1 onto visible rows.
struct ExtensionScreen: Equatable {
struct SelectionChange: Equatable {
let handler: String
let itemID: String?
}
enum Kind: Equatable {
case list
case grid(ExtensionGridLayout)
@@ -46,6 +51,7 @@ struct ExtensionScreen: Equatable {
let filtersLocally: Bool
let searchTextHandler: String?
let selectionHandler: String?
let selectedItemID: String?
let searchBarAccessory: RenderNode?
/// The `List`-level `isShowingDetail`; when set, rows get a detail pane beside them.
let showsDetail: Bool
@@ -73,8 +79,8 @@ struct ExtensionScreen: Equatable {
static let empty = ExtensionScreen(
kind: .unsupported(""), root: nil, rows: [], items: [], fields: [], isLoading: false,
navigationTitle: nil, searchPlaceholder: nil, filtersLocally: false, searchTextHandler: nil,
selectionHandler: nil, searchBarAccessory: nil, showsDetail: false, screenActions: nil,
emptyView: nil)
selectionHandler: nil, selectedItemID: nil, searchBarAccessory: nil, showsDetail: false,
screenActions: nil, emptyView: nil)
/// Filters rows by `query` only when the extension hasn't taken the search text over.
init(tree: RenderTree, query: String) {
@@ -88,6 +94,7 @@ struct ExtensionScreen: Equatable {
searchPlaceholder = root.string("searchBarPlaceholder")
searchTextHandler = root.handler("onSearchTextChange")
selectionHandler = root.handler("onSelectionChange")
selectedItemID = root.string("selectedItemId")
searchBarAccessory = root.node("searchBarAccessory")
showsDetail = root.bool("isShowingDetail") ?? false
screenActions = root.node("actions")
@@ -164,8 +171,9 @@ struct ExtensionScreen: Equatable {
private init(
kind: Kind, root: RenderNode?, rows: [Row], items: [Item], fields: [RenderNode],
isLoading: Bool, navigationTitle: String?, searchPlaceholder: String?, filtersLocally: Bool,
searchTextHandler: String?, selectionHandler: String?, searchBarAccessory: RenderNode?,
showsDetail: Bool, screenActions: RenderNode?, emptyView: RenderNode?
searchTextHandler: String?, selectionHandler: String?, selectedItemID: String?,
searchBarAccessory: RenderNode?, showsDetail: Bool, screenActions: RenderNode?,
emptyView: RenderNode?
) {
self.kind = kind
self.root = root
@@ -178,12 +186,25 @@ struct ExtensionScreen: Equatable {
self.filtersLocally = filtersLocally
self.searchTextHandler = searchTextHandler
self.selectionHandler = selectionHandler
self.selectedItemID = selectedItemID
self.searchBarAccessory = searchBarAccessory
self.showsDetail = showsDetail
self.screenActions = screenActions
self.emptyView = emptyView
}
var selectedItemIndex: Int? {
guard let selectedItemID else { return nil }
return items.firstIndex { $0.node.string("id") == selectedItemID }
}
/// Resolves the List/Grid callback after local filtering changes the visible row order.
func selectionChange(at index: Int) -> SelectionChange? {
guard let selectionHandler else { return nil }
let itemID = items.indices.contains(index) ? items[index].node.string("id") : nil
return SelectionChange(handler: selectionHandler, itemID: itemID)
}
/// Title, subtitle and keywords, ranked by the launcher's matcher.
static func matches(_ item: RenderNode, _ needle: FuzzyMatch.Query) -> Bool {
guard !needle.isEmpty else { return true }
@@ -0,0 +1,35 @@
import Foundation
import SwiftUI
/// Keeps the extension's item-id selection in sync with Tinycast's flat palette index.
struct ExtensionSelectionForwarder: ViewModifier {
let screen: ExtensionScreen
let selection: Int
@Environment(PaletteState.self) private var palette
@Environment(ExtensionManager.self) private var extensions
@State private var seededContext: String?
private var selectedIndex: Int {
screen.items.isEmpty ? 0 : min(max(selection, 0), screen.items.count - 1)
}
private var context: String? {
guard let running = extensions.running, let root = screen.root else { return nil }
return "\(running.entryID):\(root.id)"
}
func body(content: Content) -> some View {
content.onChange(of: screen.selectionChange(at: selectedIndex), initial: true) { _, change in
guard let change else { return }
if seededContext != context {
seededContext = context
if let index = screen.selectedItemIndex, selectedIndex != index {
palette.selection = index
return
}
}
let argument: Any = change.itemID.map { $0 as Any } ?? NSNull()
extensions.dispatch(handler: change.handler, arguments: [argument])
}
}
}
+1
View File
@@ -342,6 +342,7 @@ struct RootPaletteView: View {
guard previous == nil, let handler, !vm.query.isEmpty else { return }
extensions.dispatch(handler: handler, arguments: [vm.query])
}
.modifier(ExtensionSelectionForwarder(screen: extensionScreen, selection: vm.selection))
// A narrower list means the old index points at a different row, or at none.
.onChange(of: vm.clipboardFilter) {
vm.selection = 0
File diff suppressed because one or more lines are too long
+10 -5
View File
@@ -111,7 +111,7 @@ Two host-call flavours:
| --- | --- |
| `Service/ExtensionRuntime.swift` | the `JSContext`, host-function installation, timers, exception reporting |
| `Service/ExtensionHostBridge.swift` | main-actor host APIs (clipboard, storage, cache, window, toasts, system, oauth) |
| `Service/ExtensionNodeShims.swift` | the synchronous `fs` / `child_process` / `crypto` / `zlib` services |
| `Service/ExtensionNodeShims.swift` | the synchronous `fs` / `os` / `child_process` / `crypto` / `zlib` services |
| `Service/ExtensionFetcher.swift` | `fetch` over `URLSession`, plus the async `exec` and the shared PATH resolver |
| `Service/ExtensionOAuthKeychain.swift` | secure OAuth token storage backed by macOS Keychain |
| `Service/ExtensionOAuthSession.swift` | PKCE state tracking, browser launch, and callback redirect resolution |
@@ -165,6 +165,10 @@ screens hold (see [palette.md](palette.md)).
whole signal. `ExtensionScreen.Item`
carries both the flat `selection` index and the scroll id, and is the `ForEach` identity of the row
and the grid cell alike — see the scroll-id rule in [ui.md](../ui.md#rows-selection-hover).
A matching `selectedItemId` seeds the palette highlight when the screen first appears.
`onSelectionChange` is reported with that visible item's string id. The observer keys on the id,
not just the numeric index, because local filtering can replace row zero without changing the
palette selection; an empty result reports `null`, matching the API contract.
- **Search-bar dropdown** — `List.Dropdown` and `Grid.Dropdown` draw as
`ExtensionSearchAccessoryButton` at the header's trailing edge and drop `ExtensionPickerList` as one
of the palette's `OpenMenu` cases, so the arrows, ↵, Escape and the click-away come from the one menu
@@ -577,10 +581,11 @@ Two things decide whether it gets there. Axios enables that adapter only when
`Object.prototype.toString.call(process)` reads `[object process]`, so `process` carries the tag; and
follow-redirects inherits with `Writable.call(this)`, so `stream` hands out callable constructors.
**Bundled Swift helpers** — an extension that imports `swift:../swift/<package>` ships the compiled
Mach-O in `assets/`, and the wrapper Raycast generates chmods it to `755` before spawning it. Store
zips ship that binary `644`, so the chmod is what makes it runnable at all; the buffered `spawn`
covers the rest of the wrapper. Color Picker is the reference case.
**Bundled helpers** — compiled Mach-O files and shebang scripts live in `assets/`. GitHub's raw-file
downloads and some store zips lose their executable mode, so installation preserves Git tree mode
`100755`; discovery also repairs known executable payloads already installed as `644`. That covers
both generated wrappers and extensions that call a helper directly with `execFile`. The buffered
`spawn` covers the rest of a Swift wrapper. Color Picker is the reference case.
**Command modes** — `view` renders into the palette; `no-view` runs headless with the palette closed.
Both receive `props.arguments` and `props.launchType`. A `no-view` command declaring `interval`