chore(apple/M1): move widget sources into the Xcode target's synced folder
Xcode created the PunktfunkWidgetsExtension target with a file-system- synchronized root group at clients/apple/PunktfunkWidgets/, so the target compiles whatever lives there. Deleted the three generated stubs (PunktfunkWidgets.swift / PunktfunkWidgetsBundle.swift / PunktfunkWidgetsControl.swift — the stub @main WidgetBundle would collide with ours) and moved our sources (PunktfunkWidgetBundle / HostsWidget / SessionLiveActivity) from Sources/PunktfunkWidgets/ into PunktfunkWidgets/. Kept the generated Info.plist (build-excluded via the sync exception set) and Assets.xcassets. Still outside Sources/, so SwiftPM ignores it; swift build green. project.pbxproj is intentionally NOT part of this commit — the target's capability/signing edits (step 3) are still in progress in Xcode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Home-Screen / Lock-Screen quick-launch widget (kind "PunktfunkHosts"). Reads the saved-host
|
||||
// store from the shared App-Group suite, sorts most-recent-first, and deep-links each host into a
|
||||
// session via `punktfunk://connect/<uuid>` — the app's onOpenURL routes it through the normal
|
||||
// connect path (trust policy / WoL / approval all apply).
|
||||
//
|
||||
// No reachability probing in v1 (a UDP check has no place in a timeline build; WoL handles offline
|
||||
// hosts on tap). Timeline is a single `.never` entry — the app pushes reloads on store changes
|
||||
// (HostStore → WidgetCenter.reloadTimelines).
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
struct HostsEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let hosts: [StoredHost]
|
||||
}
|
||||
|
||||
struct HostsProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> HostsEntry {
|
||||
HostsEntry(date: .now, hosts: [])
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (HostsEntry) -> Void) {
|
||||
completion(HostsEntry(date: .now, hosts: Self.loadHosts()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<HostsEntry>) -> Void) {
|
||||
// Single entry, never auto-refresh: the app reloads this timeline whenever the store
|
||||
// changes (a new host, a fresh connect reordering by recency).
|
||||
let entry = HostsEntry(date: .now, hosts: Self.loadHosts())
|
||||
completion(Timeline(entries: [entry], policy: .never))
|
||||
}
|
||||
|
||||
/// Decode the shared-suite host JSON (same wire format the app writes), most-recent first.
|
||||
static func loadHosts() -> [StoredHost] {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return [] }
|
||||
return hosts.sorted {
|
||||
($0.lastConnected ?? .distantPast) > ($1.lastConnected ?? .distantPast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct HostsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "PunktfunkHosts", provider: HostsProvider()) { entry in
|
||||
HostsWidgetView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("Punktfunk Hosts")
|
||||
.description("Quick-launch your recent streaming hosts.")
|
||||
.supportedFamilies([
|
||||
.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
struct HostsWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: HostsEntry
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .systemMedium:
|
||||
MediumHostsView(hosts: entry.hosts)
|
||||
case .accessoryCircular:
|
||||
CircularHostView(host: entry.hosts.first)
|
||||
case .accessoryRectangular:
|
||||
RectangularHostView(host: entry.hosts.first)
|
||||
default: // systemSmall + fallback
|
||||
SmallHostView(host: entry.hosts.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep link that connects to a stored host.
|
||||
private func connectURL(_ host: StoredHost) -> URL {
|
||||
DeepLink.connect(host: host.id, launchID: nil).url
|
||||
}
|
||||
|
||||
private struct SmallHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
if let host {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
Spacer(minLength: 0)
|
||||
Text(host.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let last = host.lastConnected {
|
||||
Text(last, format: .relative(presentation: .named))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.widgetURL(connectURL(host))
|
||||
} else {
|
||||
EmptyHostView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediumHostsView: View {
|
||||
let hosts: [StoredHost]
|
||||
var body: some View {
|
||||
if hosts.isEmpty {
|
||||
EmptyHostView()
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Punktfunk")
|
||||
.font(.caption).bold()
|
||||
.foregroundStyle(.tint)
|
||||
ForEach(hosts.prefix(4)) { host in
|
||||
Link(destination: connectURL(host)) {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.foregroundStyle(.tint)
|
||||
Text(host.displayName)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if let last = host.lastConnected {
|
||||
Text(last, format: .relative(presentation: .named))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
Image(systemName: "play.tv.fill")
|
||||
}
|
||||
.widgetURL(host.map(connectURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct RectangularHostView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "play.tv.fill")
|
||||
Text(host?.displayName ?? "Punktfunk")
|
||||
.lineLimit(1)
|
||||
}
|
||||
.widgetURL(host.map(connectURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyHostView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "play.tv")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Open Punktfunk to add a host.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
// The widget extension's entry point. ONE extension target (bundle id io.unom.punktfunk.widgets,
|
||||
// iOS only) hosts both the launcher widgets and the Live Activity UI. It links PunktfunkShared and
|
||||
// NOTHING else — never PunktfunkKit (Rust staticlib + presentation layer would blow the widget
|
||||
// process's ~30 MB budget).
|
||||
//
|
||||
// These files are NOT part of the SwiftPM package (Package.swift doesn't declare a PunktfunkWidgets
|
||||
// target, so `swift build` ignores the directory). They compile only in the Xcode widget-extension
|
||||
// target you add pointing at this folder — see design/apple-live-activities-and-widgets.md §M1 and
|
||||
// the GUI checklist.
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct PunktfunkWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
HostsWidget()
|
||||
PunktfunkSessionLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// The Live Activity UI (Lock Screen banner + Dynamic Island) for a running session. The app owns
|
||||
// the Activity's lifecycle (SessionActivityController); this is only its presentation, rendered in
|
||||
// the widget-extension process from the shared `PunktfunkSessionAttributes`.
|
||||
//
|
||||
// The End button runs `EndStreamIntent` (a LiveActivityIntent) IN THE APP's process, which posts
|
||||
// .punktfunkEndActiveSession → the app disconnects. Elapsed time ticks client-side via
|
||||
// Text(timerInterval:) — no per-second push.
|
||||
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
struct PunktfunkSessionLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: PunktfunkSessionAttributes.self) { context in
|
||||
LockScreenView(context: context)
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label {
|
||||
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "play.tv.fill")
|
||||
}
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.caption).monospacedDigit()
|
||||
.frame(maxWidth: 56)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 6) {
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
EndButton()
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
} compactTrailing: {
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: 44)
|
||||
} minimal: {
|
||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen banner
|
||||
|
||||
private struct LockScreenView: View {
|
||||
let context: ActivityViewContext<PunktfunkSessionAttributes>
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
||||
.font(.subheadline).monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let title = context.attributes.launchTitle {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Text(context.state.modeLine)
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
StageLine(state: context.state)
|
||||
}
|
||||
if context.state.stage == .background {
|
||||
EndButton()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared pieces
|
||||
|
||||
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
||||
private struct StageLine: View {
|
||||
let state: PunktfunkSessionAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
switch state.stage {
|
||||
case .streaming:
|
||||
EmptyView()
|
||||
case .background:
|
||||
if let deadline = state.backgroundDeadline {
|
||||
Text("Keeps running for ")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
+ Text(timerInterval: Date()...deadline, countsDown: true)
|
||||
.font(.caption2).monospacedDigit()
|
||||
} else {
|
||||
badge("Running in background", .orange)
|
||||
}
|
||||
case .reconnecting:
|
||||
badge("Reconnecting…", .yellow)
|
||||
case .ending:
|
||||
badge("Session ended", .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func badge(_ text: String, _ color: Color) -> some View {
|
||||
Text(text).font(.caption2).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
|
||||
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
||||
private struct EndButton: View {
|
||||
var body: some View {
|
||||
Button(intent: EndStreamIntent()) {
|
||||
Label("End", systemImage: "stop.fill")
|
||||
.font(.caption).bold()
|
||||
}
|
||||
.tint(.red)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user