mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 00:04:44 +08:00
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:
committed by
GitHub
co-authored by
Jonas List
parent
4e88bd742a
commit
740cbd8639
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user