Fix ShareExtension "Open Marks first" + add widget + BookmarkListRow polish
All checks were successful
CI / build-and-deploy (push) Successful in 41s

- Save serverConfig to App Group on every app launch so ShareExtension
  can always find credentials without requiring a disconnect/reconnect flow
- Add MarksWidget target with RandomBookmarkWidget (random saved bookmark,
  reloads hourly) using file-based App Group storage to avoid CFPreferences issues
- Extract BookmarkListRow as shared component with editorial typography tweaks
  (17pt semibold title, 13pt domain, relative date, 32×32 favicon, italic AI summary,
  20pt podcast icon, 16pt horizontal insets)
- Add MarksWidget and ShareExtension Xcode schemes for direct run/debug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-05-24 13:11:05 -05:00
parent d87500a7dc
commit 199fc2d043
23 changed files with 1437 additions and 172 deletions

25
MarksWidget/Info.plist Normal file
View File

@@ -0,0 +1,25 @@
<?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>CFBundleDisplayName</key>
<string>MarksWidget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?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>com.apple.security.application-groups</key>
<array>
<string>group.com.magicive.marks</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,11 @@
import WidgetKit
import SwiftUI
@main
struct MarksWidgetBundle: WidgetBundle {
var body: some Widget {
RecentBookmarksWidget()
RandomBookmarkWidget()
RecentPodcastsWidget()
}
}

View File

@@ -0,0 +1,98 @@
import WidgetKit
import SwiftUI
struct RandomBookmarkEntry: TimelineEntry {
let date: Date
let bookmark: WidgetBookmark?
static let placeholder = RandomBookmarkEntry(
date: .now,
bookmark: WidgetBookmark(url: "https://example.com", title: "A Saved Article Worth Revisiting", domain: "example.com", faviconUrl: nil)
)
}
struct RandomBookmarkProvider: TimelineProvider {
func placeholder(in context: Context) -> RandomBookmarkEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (RandomBookmarkEntry) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
completion(RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RandomBookmarkEntry>) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
let entry = RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement())
let next = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
struct RandomBookmarkWidgetView: View {
let entry: RandomBookmarkEntry
var body: some View {
Group {
if let bookmark = entry.bookmark {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Rediscover")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "sparkles")
.font(.system(size: 11))
.foregroundStyle(.blue)
}
Spacer()
RoundedRectangle(cornerRadius: 7)
.fill(Color.blue.opacity(0.1))
.frame(width: 38, height: 38)
.overlay {
Image(systemName: "bookmark.fill")
.font(.system(size: 15))
.foregroundStyle(.blue)
}
Spacer().frame(height: 10)
Text(bookmark.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
Text(bookmark.domain)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(.top, 2)
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetURL(.marksBookmark(bookmark.url))
} else {
VStack(spacing: 8) {
Image(systemName: "bookmark")
.font(.system(size: 28))
.foregroundStyle(.secondary)
Text("Open Marks to\nload bookmarks.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct RandomBookmarkWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.magicive.marks.RandomBookmark",
provider: RandomBookmarkProvider()
) { entry in
RandomBookmarkWidgetView(entry: entry)
}
.configurationDisplayName("Rediscover")
.description("A random saved bookmark to revisit.")
.supportedFamilies([.systemSmall])
}
}

View File

@@ -0,0 +1,108 @@
import WidgetKit
import SwiftUI
struct RecentBookmarksEntry: TimelineEntry {
let date: Date
let bookmarks: [WidgetBookmark]
static let placeholder = RecentBookmarksEntry(date: .now, bookmarks: [
WidgetBookmark(url: "https://example.com/a", title: "The Future of AI in 2025", domain: "example.com", faviconUrl: nil),
WidgetBookmark(url: "https://news.com/b", title: "Why Swift Concurrency Matters", domain: "news.com", faviconUrl: nil),
WidgetBookmark(url: "https://blog.dev/c", title: "Building Great iOS Apps", domain: "blog.dev", faviconUrl: nil),
])
}
struct RecentBookmarksProvider: TimelineProvider {
func placeholder(in context: Context) -> RecentBookmarksEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (RecentBookmarksEntry) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
completion(RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3))))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RecentBookmarksEntry>) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
let entry = RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3)))
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
struct RecentBookmarksWidgetView: View {
let entry: RecentBookmarksEntry
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Recent")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "bookmark.fill")
.font(.system(size: 11))
.foregroundStyle(.blue)
}
.padding(.bottom, 8)
if entry.bookmarks.isEmpty {
Spacer()
Text("Save bookmarks to see them here.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Spacer()
} else {
ForEach(Array(entry.bookmarks.enumerated()), id: \.element.id) { idx, bookmark in
if idx > 0 { Divider().padding(.vertical, 5) }
Link(destination: .marksBookmark(bookmark.url)) {
WidgetBookmarkRow(bookmark: bookmark)
}
}
Spacer(minLength: 0)
}
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct WidgetBookmarkRow: View {
let bookmark: WidgetBookmark
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 5)
.fill(Color(.systemGray5))
.frame(width: 26, height: 26)
.overlay {
Image(systemName: "bookmark")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Color(.systemGray2))
}
VStack(alignment: .leading, spacing: 1) {
Text(bookmark.title)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Text(bookmark.domain)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
}
struct RecentBookmarksWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.magicive.marks.RecentBookmarks",
provider: RecentBookmarksProvider()
) { entry in
RecentBookmarksWidgetView(entry: entry)
}
.configurationDisplayName("Recent Bookmarks")
.description("Your latest saved bookmarks.")
.supportedFamilies([.systemMedium])
}
}

View File

@@ -0,0 +1,111 @@
import WidgetKit
import SwiftUI
struct RecentPodcastsEntry: TimelineEntry {
let date: Date
let podcasts: [WidgetPodcast]
static let placeholder = RecentPodcastsEntry(date: .now, podcasts: [
WidgetPodcast(articleUrl: "https://example.com/a", title: "The Future of AI", createdAt: .now),
WidgetPodcast(articleUrl: "https://news.com/b", title: "Swift Concurrency Deep Dive", createdAt: .now.addingTimeInterval(-86400)),
WidgetPodcast(articleUrl: "https://blog.dev/c", title: "Building Great iOS Apps", createdAt: .now.addingTimeInterval(-172800)),
])
}
struct RecentPodcastsProvider: TimelineProvider {
func placeholder(in context: Context) -> RecentPodcastsEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (RecentPodcastsEntry) -> Void) {
let podcasts = WidgetDataStore.loadPodcasts()
completion(RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3))))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RecentPodcastsEntry>) -> Void) {
let podcasts = WidgetDataStore.loadPodcasts()
let entry = RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3)))
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
struct RecentPodcastsWidgetView: View {
let entry: RecentPodcastsEntry
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Podcasts")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "headphones")
.font(.system(size: 11))
.foregroundStyle(.blue)
}
.padding(.bottom, 8)
if entry.podcasts.isEmpty {
Spacer()
Text("Generate podcasts from your bookmarks to see them here.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Spacer()
} else {
ForEach(Array(entry.podcasts.enumerated()), id: \.element.id) { idx, podcast in
if idx > 0 { Divider().padding(.vertical, 5) }
Link(destination: .marksPodcast(podcast.articleUrl)) {
WidgetPodcastRow(podcast: podcast)
}
}
Spacer(minLength: 0)
}
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct WidgetPodcastRow: View {
let podcast: WidgetPodcast
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 5)
.fill(Color.blue.opacity(0.1))
.frame(width: 26, height: 26)
.overlay {
Image(systemName: "waveform")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.blue)
}
VStack(alignment: .leading, spacing: 1) {
Text(podcast.title)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Text(podcast.createdAt, style: .relative)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Image(systemName: "play.circle.fill")
.font(.system(size: 18))
.foregroundStyle(.blue.opacity(0.8))
}
}
}
struct RecentPodcastsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.magicive.marks.RecentPodcasts",
provider: RecentPodcastsProvider()
) { entry in
RecentPodcastsWidgetView(entry: entry)
}
.configurationDisplayName("Recent Podcasts")
.description("Tap to play recently generated podcasts.")
.supportedFamilies([.systemMedium])
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
import OSLog
#if canImport(WidgetKit)
import WidgetKit
#endif
private let log = Logger(subsystem: "com.magicive.marks", category: "WidgetDataStore")
struct WidgetBookmark: Codable, Identifiable, Sendable {
var id: String { url }
let url: String
let title: String
let domain: String
let faviconUrl: String?
}
struct WidgetPodcast: Codable, Identifiable, Sendable {
var id: String { articleUrl }
let articleUrl: String
let title: String
let createdAt: Date
}
enum WidgetDataStore {
static let groupID = "group.com.magicive.marks"
private static var containerURL: URL? {
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)
if url == nil { log.error("App Group container not found — group ID: \(groupID)") }
return url
}
private static func fileURL(_ name: String) -> URL? {
containerURL?.appendingPathComponent(name)
}
private static let encoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()
private static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
// MARK: - Bookmarks
static func saveBookmarks(_ bookmarks: [WidgetBookmark]) {
guard let url = fileURL("widget_bookmarks.json"),
let data = try? encoder.encode(bookmarks) else { return }
try? data.write(to: url, options: .atomic)
reloadTimelines()
}
static func loadBookmarks() -> [WidgetBookmark] {
guard let url = fileURL("widget_bookmarks.json") else {
log.error("loadBookmarks: no container URL")
return []
}
guard let data = try? Data(contentsOf: url) else {
log.warning("loadBookmarks: file not found at \(url.path)")
return []
}
guard let items = try? decoder.decode([WidgetBookmark].self, from: data) else {
log.error("loadBookmarks: decode failed")
return []
}
log.info("loadBookmarks: loaded \(items.count) bookmarks")
return items
}
// MARK: - Podcasts
static func savePodcasts(_ podcasts: [WidgetPodcast]) {
guard let url = fileURL("widget_podcasts.json"),
let data = try? encoder.encode(podcasts) else { return }
try? data.write(to: url, options: .atomic)
reloadTimelines()
}
static func loadPodcasts() -> [WidgetPodcast] {
guard let url = fileURL("widget_podcasts.json"),
let data = try? Data(contentsOf: url),
let items = try? decoder.decode([WidgetPodcast].self, from: data)
else { return [] }
return items
}
private static func reloadTimelines() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
}
}
// MARK: - Deep link URL helpers
extension URL {
static func marksBookmark(_ url: String) -> URL {
var c = URLComponents()
c.scheme = "marks"
c.host = "bookmark"
c.queryItems = [URLQueryItem(name: "url", value: url)]
return c.url ?? URL(string: "marks://bookmark")!
}
static func marksPodcast(_ url: String) -> URL {
var c = URLComponents()
c.scheme = "marks"
c.host = "podcast"
c.queryItems = [URLQueryItem(name: "url", value: url)]
return c.url ?? URL(string: "marks://podcast")!
}
}