Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,97 +1,74 @@
//
// ScriptWidget
// https://xnu.app/scriptwidget
//
// Activity Rings: three goal rings and a week of move calories.
// Replace the sample values with your own (or $health data).
//

// Activity Rings · an offline design example with editable sample values.
// These values are illustrative, not live Apple Health readings.
const size = $getenv("widget-size");
const dark = $device.isdarkmode();
const palette = {
background: dark ? "#172522" : "#F5FAF7",
text: dark ? "#F0F7F3" : "#203D32",
secondary: dark ? "#B0C5BA" : "#5C7568",
track: dark ? "#2B4138" : "#E0EBE4",
};
const goals = [
{ name: "Move", value: 420, goal: 500, unit: "CAL", color: "#FA114F", track: "#3A0B18" },
{ name: "Exercise", value: 24, goal: 30, unit: "MIN", color: "#92E82A", track: "#20330A" },
{ name: "Stand", value: 9, goal: 12, unit: "HRS", color: "#1EEAEF", track: "#0A3334" },
{ name: "Move", value: 420, goal: 500, unit: "cal", color: dark ? "#F2AC83" : "#AC582D" },
{ name: "Exercise", value: 24, goal: 30, unit: "min", color: dark ? "#74C8A2" : "#287A59" },
{ name: "Stand", value: 9, goal: 12, unit: "hr", color: dark ? "#8BBBE0" : "#3D739E" },
];
const week = [["M", 510], ["T", 380], ["W", 620], ["T", 450], ["F", 290], ["S", 540], ["S", 420]];

const Header = () => (
<hstack frame="max,14" spacing="4">
<text font="caption" fontWeight="semibold" color={palette.text}>Activity</text>
<spacer />
<text font="caption2" color={palette.secondary}>Sample</text>
</hstack>
);
const Rings = ({ side, thickness }) => (
<zstack frame={`${side},${side}`}>
{goals.map((goal, index) => {
const inset = index * (thickness + 2);
return <ring value={Math.min(1, goal.value / goal.goal)} thickness={thickness} color={goal.color} trackColor={goal.track} frame={`${side - inset * 2},${side - inset * 2}`} />;
const inset = index * (thickness + 3);
return <ring value={Math.min(1, goal.value / goal.goal)} thickness={thickness} color={goal.color} trackColor={palette.track} frame={`${side - inset * 2},${side - inset * 2}`} />;
})}
</zstack>
);
const Stat = ({ goal, font }) => (
<vstack alignment="leading" spacing="0">
<text font="caption2" fontWeight="semibold" color="white">{goal.name}</text>
<hstack alignment="bottom" spacing="3"><text font={font} fontWeight="semibold" color={goal.color}>{goal.value}/{goal.goal}</text><text font="caption" fontWeight="semibold" color={goal.color}>{goal.unit}</text></hstack>
</vstack>
const Stat = ({ goal }) => (
<hstack spacing="5" frame="max,20">
<circle color={goal.color} frame="5,5" />
<text font="caption" color={palette.secondary}>{goal.name}</text>
<spacer />
<text font="headline" fontWeight="semibold" color={palette.text}>{goal.value}</text>
<text font="caption2" color={palette.secondary}>{goal.unit}</text>
</hstack>
);
const Summary = () => (
<hstack spacing="8" frame="max,38">
{goals.map((goal) => <vstack spacing="2" frame="max">
<hstack spacing="2"><text font="headline" fontWeight="semibold" color={goal.color}>{goal.value}</text><text font="caption2" color={palette.secondary}>{goal.unit}</text></hstack>
<text font="caption2" color={palette.secondary}>{goal.name}</text>
</vstack>)}
</hstack>
);
const Week = () => (
<vstack alignment="leading" spacing="8" frame="max,leading">
<text font="caption" fontWeight="semibold" color="#8E8E93">MOVE THIS WEEK</text>
<hstack alignment="bottom" frame="max,70">
{week.map(([, calories]) => <capsule frame={`14,${Math.round(70 * calories / 620)}`} color={calories >= goals[0].goal ? "#FA114F" : "#5C1527"} />).flatMap((bar, index) => (index ? [<spacer />, bar] : [bar]))}
<vstack alignment="leading" spacing="6" frame="max,82">
<hstack><text font="caption2" fontWeight="semibold" color={palette.secondary}>MOVE THIS WEEK</text><spacer /><text font="caption2" color={palette.secondary}>500 cal goal</text></hstack>
<hstack alignment="bottom" frame="max,42">
{week.map(([, calories]) => <capsule frame={`12,${Math.round(42 * calories / 620)}`} color={calories >= goals[0].goal ? goals[0].color : palette.track} />).flatMap((bar, index) => index ? [<spacer />, bar] : [bar])}
</hstack>
<hstack>
{week.map(([day]) => <text font="caption2" color="#8E8E93" frame="14">{day}</text>).flatMap((label, index) => (index ? [<spacer />, label] : [label]))}
{week.map(([day]) => <text font="caption2" color={palette.secondary} frame="12">{day}</text>).flatMap((label, index) => index ? [<spacer />, label] : [label])}
</hstack>
</vstack>
);

if (size === "accessoryCircular") {
$render(<zstack><ring value={goals[0].value / goals[0].goal} thickness="5" frame="max" /><text font="caption" fontWeight="bold">{Math.round(100 * goals[0].value / goals[0].goal)}%</text></zstack>);
} else if (size === "accessoryRectangular") {
$render(
<hstack spacing="8" frame="max,leading">
<Rings side={44} thickness={5} />
<vstack alignment="leading" spacing="0">
{goals.map((goal) => <text font="caption">{goal.value}/{goal.goal} {goal.unit}</text>)}
</vstack>
</hstack>
);
$render(<hstack spacing="8" frame="max,leading"><Rings side={44} thickness={4} /><vstack alignment="leading" spacing="0">{goals.map((goal) => <text font="caption">{goal.value}/{goal.goal} {goal.unit}</text>)}</vstack></hstack>);
} else if (size === "accessoryInline") {
$render(<text>Move {goals[0].value}/{goals[0].goal} · Exercise {goals[1].value} min</text>);
} else if (size === "small") {
$render(
<vstack spacing="10" padding="14" background="black">
<Rings side={84} thickness={11} />
<hstack>
{goals.map((goal) => <text font="caption" fontWeight="bold" color={goal.color} frame="max">{goal.value}</text>)}
</hstack>
</vstack>
);
$render(<vstack frame="max" spacing="8" padding="14" background={palette.background}><Header /><Rings side={76} thickness={8} /><hstack frame="max">{goals.map((goal) => <vstack frame="max" spacing="0"><text font="caption" fontWeight="semibold" color={goal.color}>{goal.value}</text><text font="caption2" color={palette.secondary}>{goal.unit}</text></vstack>)}</hstack></vstack>);
} else if (size === "medium") {
$render(
<hstack spacing="18" padding="16" background="black">
<Rings side={112} thickness={14} />
<vstack alignment="leading" spacing="6" frame="max,leading">
{goals.map((goal) => <Stat goal={goal} font="title3" />)}
</vstack>
</hstack>
);
$render(<vstack frame="max" spacing="10" padding="16" background={palette.background}><Header /><hstack spacing="20" frame="max,96"><Rings side={96} thickness={10} /><vstack frame="max,96,leading" spacing="10">{goals.map((goal) => <Stat goal={goal} />)}</vstack></hstack></vstack>);
} else if (size === "extraLarge") {
$render(
<hstack spacing="32" padding="24" background="black">
<Rings side={180} thickness={20} />
<vstack alignment="leading" spacing="10" frame="160,leading">
{goals.map((goal) => <Stat goal={goal} font="title2" />)}
</vstack>
<Week />
</hstack>
);
$render(<vstack frame="max" spacing="18" padding="22" background={palette.background}><Header /><hstack spacing="28" frame="max"><Rings side={148} thickness={16} /><vstack frame="140,148,leading" spacing="16">{goals.map((goal) => <Stat goal={goal} />)}</vstack><Week /></hstack></vstack>);
} else {
$render(
<vstack spacing="18" padding="16" background="black">
<hstack spacing="20">
<Rings side={128} thickness={16} />
<vstack alignment="leading" spacing="8" frame="max,leading">
{goals.map((goal) => <Stat goal={goal} font="title2" />)}
</vstack>
</hstack>
<spacer />
<Week />
</vstack>
);
$render(<vstack frame="max" spacing="12" padding="18" background={palette.background}><Header /><spacer /><Rings side={110} thickness={12} /><Summary /><spacer /><Week /></vstack>);
}
48 changes: 28 additions & 20 deletions Tests/ScriptWidgetRuntimeTests/RuntimeExecutionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -675,35 +675,21 @@ final class RuntimeExecutionTests: XCTestCase {
}
}

/// CPU seconds this process consumes over `interval`.
private func processCPUSeconds(over interval: TimeInterval) -> Double {
func cpuSeconds() -> Double {
var usage = rusage()
getrusage(RUSAGE_SELF, &usage)
func seconds(_ time: timeval) -> Double { Double(time.tv_sec) + Double(time.tv_usec) / 1_000_000 }
return seconds(usage.ru_utime) + seconds(usage.ru_stime)
}
let before = cpuSeconds()
Thread.sleep(forTimeInterval: interval)
return cpuSeconds() - before
}

/// Asserts the runaway script released its thread and its VM.
/// JavaScriptCore serialises entry into a VM, so while a loop spins a
/// follow-up execution on the same runtime blocks until it, too, hits the
/// execution limit. Runtimes are single-use, so the follow-up fails on
/// redeclaring util.js globals; reaching that error promptly proves the
/// VM accepted new work.
/// VM accepted new work. Process-wide CPU sampling is not evidence about
/// this VM: compiler, WebKit and other hosted-test work can run concurrently.
private func assertRuntimeStopped(_ runtime: ScriptWidgetRuntime, file: StaticString = #filePath, line: UInt = #line) {
let idleCPU = processCPUSeconds(over: 1)
XCTAssertLessThan(idleCPU, 0.5, "a runaway script is still burning CPU", file: file, line: line)

let start = Date()
let (_, error) = runtime.executeJSXSyncForWidget("$render(<text>after</text>);")
XCTAssertLessThan(Date().timeIntervalSince(start), 3, "runtime VM still locked", file: file, line: line)
if case .resourceLimit = error {
XCTFail("runtime VM still busy: \(String(describing: error))", file: file, line: line)
guard case .scriptException(let message) = error else {
return XCTFail("Expected the same VM to report a JavaScript redeclaration, got \(String(describing: error))", file: file, line: line)
}
XCTAssertTrue(message.hasPrefix("SyntaxError:"), "Expected JavaScriptCore to accept follow-up work, got \(message)", file: file, line: line)
}

func testRunawaySynchronousLoopIsTerminatedAtTimeLimit() {
Expand Down Expand Up @@ -4401,6 +4387,26 @@ final class LibrarySessionExecutionTests: XCTestCase {
try prepareFunctionalTranspile(captured.source)
try prepareFunctionalTranspile(try XCTUnwrap(captured.package.readFile(relativePath:"lib/weather.js").0), wrapMain:false)
let runtime=ScriptWidgetRuntime(package:captured.package,environments:["widget-size":"medium"],executionSnapshot:captured)
// This test covers HTTP/cache persistence, not the simulator's
// location daemon. Even a manually configured city checks location
// availability so it can discard revoked location cache entries.
let locationChecked = expectation(description: "Weather uses isolated location availability")
runtime.validationHostInstaller = { context in
let unavailable: @convention(block) () -> Bool = {
locationChecked.fulfill()
return false
}
context["__weatherLocationUnavailable"] = unsafeBitCast(unavailable, to: JSValue.self)
context.evaluateScript("""
globalThis.$location = Object.freeze({
isAvailable: __weatherLocationUnavailable,
authorizationStatus: () => 'unavailable',
isAuthorizedForWidgetUpdates: () => false,
current: () => { throw new Error('Manual weather must not request location'); }
});
delete globalThis.__weatherLocationUnavailable;
""")
}
let configuration=URLSessionConfiguration.ephemeral;configuration.protocolClasses=[NativeIdentityURLProtocol.self]
let transport = ScriptWidgetFetchManager(configuration: configuration)
// Exercise persistence through the real bridge, without timing cold
Expand All @@ -4423,7 +4429,9 @@ final class LibrarySessionExecutionTests: XCTestCase {
}
runtime.runningState?.fetchManagerForTesting = transport
let result=runtime.executeJSXSyncForWidget(captured.source)
XCTAssertNil(result.1,result.1?.displayMessage ?? "")
XCTAssertEqual(XCTWaiter.wait(for: [locationChecked], timeout: 0), .completed)
let trace = ScriptWidgetRuntimeTrace.recentSnapshots().last
XCTAssertNil(result.1, "\(result.1?.displayMessage ?? ""); HTTP calls: \(calls); phases: \(trace?.durations ?? [:]); counters: \(trace?.counters ?? [:])")
return text(try XCTUnwrap(result.0))
}
XCTAssertTrue(try execute(first).contains("18°C"));XCTAssertEqual(calls,1)
Expand Down
17 changes: 17 additions & 0 deletions Tests/ScriptWidgetRuntimeTests/RuntimeUnitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import ImageIO
import CryptoKit
import Darwin
import SwiftUI
import UniformTypeIdentifiers
#if os(iOS)
import UIKit
#elseif os(macOS)
Expand Down Expand Up @@ -1512,6 +1513,22 @@ final class ScriptManagerLifecycleTests: XCTestCase {
XCTAssertEqual(manager.listScripts().map(\.name), ["Renamed Remix"])
}

func testExportedWidgetArchiveIsSelectableByZIPImporter() throws {
let manager = try makeManager("ArchiveFileType")
XCTAssertTrue(manager.createScript(content: "$render(<text>Archive</text>)",
recommendPackageName: "Archive", imageCopyPath: nil).0)
let model = try XCTUnwrap(manager.listScripts().first)
let ext = URL(fileURLWithPath: model.exportFileName).pathExtension
XCTAssertEqual(ext, "swt", "Keep existing exported archives compatible")
let declarations = try XCTUnwrap(Bundle.main.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]])
let archive = try XCTUnwrap(declarations.first { ($0["UTTypeIdentifier"] as? String) == "com.everettjf.scriptwidget.archive" })
XCTAssertEqual(archive["UTTypeConformsTo"] as? [String], [UTType.zip.identifier])
let tags = try XCTUnwrap(archive["UTTypeTagSpecification"] as? [String: Any])
XCTAssertEqual(tags["public.filename-extension"] as? [String], [ext])
let type = try XCTUnwrap(UTType(filenameExtension: ext))
XCTAssertTrue(type.conforms(to: .zip), "The system ZIP picker must accept the app's own .swt export")
}

func testExportImportRoundTripDeduplicatesWithoutOverwrite() throws {
let source = try makeManager("ExportSource")
XCTAssertTrue(source.createScript(
Expand Down
Loading
Loading