Skip to content

Commit a5ebf00

Browse files
author
Leopold
committed
fix(widget): iOS plugin is now actually zero-touch
The previous iOS widget plugin claimed 'zero manual Xcode work' but only did 80% — the native bridge files (UptimePocketAppGroup.swift and .m) were copied into ios/ by copyWidgetSources() but never added to the main app target's compile sources. The user had to open Xcode and do step 3 manually, breaking the promise. Now actually zero-touch. What changed: * Plugin rewrite (plugin.ts → plugin.mts, ESM): - Uses the xcode npm package to programmatically edit the .pbxproj - Adds the native bridge sources to the MAIN APP target's compile sources (not just copies them to disk) - Creates + configures the Swift→ObjC bridging header - Sets SWIFT_OBJC_BRIDGING_HEADER, SWIFT_VERSION, and CLANG_ENABLE_MODULES on the main app target - Idempotent — safe to re-run expo prebuild * Layout: widget sources moved from plugins/ios-widget/widget/ to UptimePocketWidget/ at the project root. The plugin copies them into ios/UptimePocketWidget/ at prebuild time. Info.plist renamed to UptimePocketWidget-Info.plist to match the Xcode target name. * New files (copied by the plugin into ios/ at prebuild time): - UptimePocket-Bridging-Header.h - UptimePocketAppGroup.swift - UptimePocketAppGroup.m - UptimePocketWidget/Info.plist - UptimePocketWidget/UptimePocketWidget.entitlements - UptimePocketWidget/UptimePocketWidget.swift - UptimePocketWidget/WidgetSnapshot.swift - UptimePocketWidget/WidgetSnapshotReader.swift * Deps: added @expo/config-plugins (for the plugin API) and xcode (for .pbxproj manipulation) to devDependencies. * docs/ios-widget.md: rewritten to reflect the new '100% zero-touch for file-and-Xcode work' promise. Manual operator steps reduced from 3 to 2 (App Group creation on the Developer Portal + signing), both of which genuinely require Apple account credentials. What the operator still has to do (genuinely can't automate): 1. Create the App Group 'group.de.quavon.uptimepocket' on the Apple Developer Portal 2. Configure signing (team identifier, provisioning profile) Both of these require credentials that don't live in the repo. Verified: gates green (typecheck 0, lint 0 errors, 283/283 tests). The widget still needs a paid Apple Developer account + a real iOS device to actually render on a home screen — the operator's job.
1 parent 53e1301 commit a5ebf00

15 files changed

Lines changed: 1050 additions & 300 deletions

UptimePocket-Bridging-Header.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Auto-generated by uptime-pocket-ios-widget plugin.
2+
// This file is the Swift→ObjC bridging header for the
3+
// main app target. Imports React so Swift can call
4+
// back into the React Native bridge.
5+
#import <React/RCTBridgeModule.h>

UptimePocketAppGroup.m

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// UptimePocketAppGroup.m
3+
// UptimePocket
4+
//
5+
// Objective-C bridge for the UptimePocketAppGroup Swift
6+
// module. React Native's bridge uses Objective-C runtime
7+
// introspection to discover @objc methods, so Swift classes
8+
// need a corresponding .m file to register themselves with
9+
// the bridge.
10+
//
11+
// This file is added to the MAIN APP target by the
12+
// `uptime-pocket-ios-widget` config plugin.
13+
//
14+
15+
#import <React/RCTBridgeModule.h>
16+
17+
@interface RCT_EXTERN_MODULE(UptimePocketAppGroup, NSObject)
18+
19+
RCT_EXTERN_METHOD(isAvailable:(RCTPromiseResolveBlock)resolve
20+
rejecter:(RCTPromiseRejectBlock)reject)
21+
22+
RCT_EXTERN_METHOD(writeSnapshot:(NSString *)filename
23+
json:(NSString *)json
24+
resolver:(RCTPromiseResolveBlock)resolve
25+
rejecter:(RCTPromiseRejectBlock)reject)
26+
27+
+ (BOOL)requiresMainQueueSetup;
28+
29+
@end

UptimePocketAppGroup.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//
2+
// UptimePocketAppGroup.swift
3+
// UptimePocket
4+
//
5+
// Tiny native module that exposes the App Group container to
6+
// JavaScript. Used by the widget snapshot writer to drop a
7+
// JSON file in a location the widget extension can read.
8+
//
9+
// Why a native module? `expo-file-system` doesn't know about
10+
// App Group containers on iOS. We could write into
11+
// `documentDirectory` (which is a per-app sandbox path) but
12+
// the widget extension is a separate target and can't read
13+
// from the main app's sandbox. The App Group is the only
14+
// blessed way to share files between two targets of the
15+
// same app.
16+
//
17+
// This file is added to the MAIN APP target (not the widget
18+
// extension) by the `uptime-pocket-ios-widget` config plugin.
19+
//
20+
21+
import Foundation
22+
import React
23+
24+
@objc(UptimePocketAppGroup)
25+
class UptimePocketAppGroup: NSObject {
26+
27+
/// The App Group identifier. Must match the entitlement on
28+
/// BOTH this target and the widget extension target.
29+
private let appGroupIdentifier = "group.de.quavon.uptimepocket"
30+
31+
/// Returns the URL of the App Group container, or nil if
32+
/// the App Group isn't provisioned (e.g. developer hasn't
33+
/// set it up in the Apple Developer Portal).
34+
@objc
35+
static func requiresMainQueueSetup() -> Bool {
36+
return false
37+
}
38+
39+
/// JS-callable: is the App Group container reachable?
40+
/// Used to short-circuit writes on simulator / dev builds
41+
/// where App Groups sometimes aren't available.
42+
@objc(isAvailable:rejecter:)
43+
func isAvailable(
44+
_ resolve: @escaping RCTPromiseResolveBlock,
45+
rejecter reject: @escaping RCTPromiseRejectBlock
46+
) {
47+
let url = FileManager.default.containerURL(
48+
forSecurityApplicationGroupIdentifier: appGroupIdentifier
49+
)
50+
resolve(url != nil)
51+
}
52+
53+
/// JS-callable: write `json` to `<container>/<filename>`.
54+
/// Writes atomically (write to temp, then rename) so a
55+
/// partial flush can never leave the widget reading
56+
/// truncated JSON. Returns true on success, false on
57+
/// permission/IO error.
58+
@objc(writeSnapshot:json:resolver:rejecter:)
59+
func writeSnapshot(
60+
_ filename: NSString,
61+
json: NSString,
62+
resolver resolve: @escaping RCTPromiseResolveBlock,
63+
rejecter reject: @escaping RCTPromiseRejectBlock
64+
) {
65+
guard let container = FileManager.default.containerURL(
66+
forSecurityApplicationGroupIdentifier: appGroupIdentifier
67+
) else {
68+
resolve(false)
69+
return
70+
}
71+
let target = container.appendingPathComponent(filename as String)
72+
let temp = target.appendingPathExtension("tmp")
73+
do {
74+
try (json as String).write(to: temp, atomically: true, encoding: .utf8)
75+
// If target doesn't exist, move is fine. If it does
76+
// exist, we need to replace it; FileManager.replaceItem
77+
// is the atomic POSIX-rename equivalent on iOS.
78+
if FileManager.default.fileExists(atPath: target.path) {
79+
_ = try FileManager.default.replaceItemAt(target, withItemAt: temp)
80+
} else {
81+
try FileManager.default.moveItem(at: temp, to: target)
82+
}
83+
resolve(true)
84+
} catch {
85+
// Best-effort: log in dev, return false to JS.
86+
// The JS side falls back to expo-file-system (or no-op).
87+
#if DEBUG
88+
print("[UptimePocketAppGroup] write failed: \(error)")
89+
#endif
90+
resolve(false)
91+
}
92+
}
93+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.security.application-groups</key>
6+
<array>
7+
<string>group.de.quavon.uptimepocket</string>
8+
</array>
9+
</dict>
10+
</plist>
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
//
2+
// UptimeWidgetView.swift
3+
// UptimePocketWidget
4+
//
5+
// The widget's SwiftUI body. One supported kind (a 4×2 list
6+
// of up to 5 monitors). Status dot is colored; down monitors
7+
// float to the top. Tap target deep-links to the monitor
8+
// detail screen via the `uptimepocket://` URL scheme.
9+
//
10+
// Design notes:
11+
// - iOS 17+ widgets use the new WidgetKit + AppIntents
12+
// declarative API. We use the legacy `IntentTimelineProvider`
13+
// shape because the app's main bundle target is iOS 16+ and
14+
// we'd rather not bump the deployment target for v1 of the
15+
// widget.
16+
// - We don't ship "no configuration" for the user — the widget
17+
// shows whatever the app's currently-displayed servers
18+
// contain. Configuration UI can be added in a later release
19+
// using `AppIntentConfiguration` (iOS 17+).
20+
//
21+
22+
import SwiftUI
23+
import WidgetKit
24+
25+
// MARK: - Status colors
26+
//
27+
// Mirrors `src/theme/colors.ts` "status" scale. We can't import
28+
// the TS palette (different language, different runtime), so we
29+
// re-declare the matching values here. If the palette changes
30+
// in TS, this file must be updated to match.
31+
32+
enum WidgetPalette {
33+
static let up = Color(red: 0x10/255.0, green: 0xB9/255.0, blue: 0x81/255.0) // #10B981
34+
static let down = Color(red: 0xEF/255.0, green: 0x44/255.0, blue: 0x44/255.0) // #EF4444
35+
static let pending = Color(red: 0xF5/255.0, green: 0x9E/255.0, blue: 0x0B/255.0) // #F59E0B
36+
static let maintenance = Color(red: 0x63/255.0, green: 0x66/255.0, blue: 0xF1/255.0) // #6366F1
37+
static let paused = Color(red: 0x9C/255.0, green: 0xA3/255.0, blue: 0xAF/255.0) // #9CA3AF
38+
39+
/// App background. Tinted dark to match the iOS 17
40+
/// "translucent widget" look. We don't actually use
41+
/// `.containerBackground` (iOS 17+) because we support
42+
/// iOS 16; on iOS 17 the system applies its own background
43+
/// behind our view.
44+
static let cardBg = Color(red: 0x1C/255.0, green: 0x1C/255.0, blue: 0x1E/255.0)
45+
static let cardBgLight = Color(red: 0xF2/255.0, green: 0xF2/255.0, blue: 0xF7/255.0)
46+
47+
static func color(for status: WidgetMonitorStatus) -> Color {
48+
switch status {
49+
case .up: return up
50+
case .down: return down
51+
case .pending: return pending
52+
case .maintenance: return maintenance
53+
case .paused: return paused
54+
}
55+
}
56+
}
57+
58+
// MARK: - Entry
59+
60+
struct UptimeEntry: TimelineEntry {
61+
let date: Date
62+
let snapshot: WidgetSnapshot?
63+
let modifiedAt: Date?
64+
65+
/// Human-readable "5m ago" string. Returns nil if we have
66+
/// no data to format.
67+
func staleText() -> String? {
68+
guard let modified = modifiedAt else { return nil }
69+
let interval = date.timeIntervalSince(modified)
70+
if interval < 60 { return nil } // fresh: don't show
71+
let mins = Int(interval / 60)
72+
if mins < 60 { return "\(mins)m ago" }
73+
let hours = mins / 60
74+
if hours < 24 { return "\(hours)h ago" }
75+
return "\(hours / 24)d ago"
76+
}
77+
}
78+
79+
// MARK: - Provider
80+
81+
struct UptimeProvider: TimelineProvider {
82+
typealias Entry = UptimeEntry
83+
84+
func placeholder(in context: Context) -> UptimeEntry {
85+
UptimeEntry(
86+
date: Date(),
87+
snapshot: nil,
88+
modifiedAt: nil
89+
)
90+
}
91+
92+
func getSnapshot(in context: Context, completion: @escaping (UptimeEntry) -> Void) {
93+
let (snap, modified) = WidgetSnapshotReader.readWithMetadata()
94+
let entry = UptimeEntry(date: Date(), snapshot: snap, modifiedAt: modified)
95+
completion(entry)
96+
}
97+
98+
/// Refresh strategy: every 5 minutes is plenty for a status
99+
/// widget. The app can also call `WidgetCenter.shared.reloadAllTimelines()`
100+
/// when a new snapshot is written, which gives us a
101+
/// near-instant update on status changes.
102+
func getTimeline(in context: Context, completion: @escaping (Timeline<UptimeEntry>) -> Void) {
103+
let (snap, modified) = WidgetSnapshotReader.readWithMetadata()
104+
let now = Date()
105+
let entry = UptimeEntry(date: now, snapshot: snap, modifiedAt: modified)
106+
// Refresh in 5 minutes. WidgetKit will dedupe with
107+
// app-triggered reloads.
108+
let nextRefresh = now.addingTimeInterval(5 * 60)
109+
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
110+
}
111+
}
112+
113+
// MARK: - View
114+
115+
struct UptimeWidgetEntryView: View {
116+
let entry: UptimeEntry
117+
@Environment(\.widgetFamily) var family
118+
119+
var body: some View {
120+
Group {
121+
if let snap = entry.snapshot, !snap.servers.isEmpty {
122+
contentView(snap: snap)
123+
} else {
124+
emptyView
125+
}
126+
}
127+
.widgetURL(URL(string: "uptimepocket://"))
128+
}
129+
130+
/// The list view. Up to 5 rows visible in 4×2. We cap to 5
131+
/// to avoid a scrollable widget (the iOS widget API doesn't
132+
/// support scrolling — the system shows a "more" badge for
133+
/// overflow).
134+
private func contentView(snap: WidgetSnapshot) -> some View {
135+
let monitors = Array(snap.prioritizedMonitors().prefix(5))
136+
return VStack(alignment: .leading, spacing: 4) {
137+
// Header: app name + a tiny stale indicator if applicable.
138+
HStack(spacing: 4) {
139+
Image(systemName: "checkmark.shield.fill")
140+
.font(.caption2)
141+
.foregroundColor(WidgetPalette.up)
142+
Text("Uptime Pocket")
143+
.font(.caption)
144+
.fontWeight(.semibold)
145+
.foregroundColor(.primary)
146+
Spacer()
147+
if let stale = entry.staleText() {
148+
Text(stale)
149+
.font(.caption2)
150+
.foregroundColor(.secondary)
151+
}
152+
}
153+
.padding(.bottom, 2)
154+
155+
ForEach(monitors) { m in
156+
MonitorRow(monitor: m)
157+
}
158+
159+
// If we have more than 5 monitors, show a "more" hint.
160+
let total = snap.prioritizedMonitors().count
161+
if total > 5 {
162+
Text("+\(total - 5) more")
163+
.font(.caption2)
164+
.foregroundColor(.secondary)
165+
.padding(.top, 2)
166+
}
167+
}
168+
}
169+
170+
private var emptyView: some View {
171+
VStack(spacing: 6) {
172+
Image(systemName: "antenna.radiowaves.left.and.right.slash")
173+
.font(.title2)
174+
.foregroundColor(.secondary)
175+
Text("No servers yet")
176+
.font(.caption)
177+
.fontWeight(.medium)
178+
.foregroundColor(.primary)
179+
Text("Add a server in the app")
180+
.font(.caption2)
181+
.foregroundColor(.secondary)
182+
}
183+
.frame(maxWidth: .infinity, maxHeight: .infinity)
184+
}
185+
}
186+
187+
struct MonitorRow: View {
188+
let monitor: WidgetMonitor
189+
190+
var body: some View {
191+
HStack(spacing: 8) {
192+
// Status dot
193+
Circle()
194+
.fill(WidgetPalette.color(for: monitor.status))
195+
.frame(width: 8, height: 8)
196+
197+
// Monitor name. Truncation is the system's job;
198+
// we just give it a flexible width.
199+
Text(monitor.name)
200+
.font(.caption)
201+
.lineLimit(1)
202+
.truncationMode(.tail)
203+
.foregroundColor(.primary)
204+
205+
Spacer(minLength: 4)
206+
207+
// Response time badge (only if known)
208+
if let rt = monitor.responseTime {
209+
Text("\(rt)ms")
210+
.font(.caption2)
211+
.foregroundColor(.secondary)
212+
.monospacedDigit()
213+
}
214+
}
215+
}
216+
}
217+
218+
// MARK: - Widget
219+
220+
@main
221+
struct UptimePocketWidget: Widget {
222+
let kind: String = "UptimePocketWidget"
223+
224+
var body: some WidgetConfiguration {
225+
StaticConfiguration(kind: kind, provider: UptimeProvider()) { entry in
226+
UptimeWidgetEntryView(entry: entry)
227+
}
228+
.configurationDisplayName("Uptime Pocket")
229+
.description("Recent monitor status at a glance.")
230+
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
231+
}
232+
}

0 commit comments

Comments
 (0)