Fix ShareExtension "Open Marks first" + add widget + BookmarkListRow polish
All checks were successful
CI / build-and-deploy (push) Successful in 41s
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:
25
MarksWidget/Info.plist
Normal file
25
MarksWidget/Info.plist
Normal 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>
|
||||
10
MarksWidget/MarksWidget.entitlements
Normal file
10
MarksWidget/MarksWidget.entitlements
Normal 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>
|
||||
11
MarksWidget/MarksWidgetBundle.swift
Normal file
11
MarksWidget/MarksWidgetBundle.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MarksWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
RecentBookmarksWidget()
|
||||
RandomBookmarkWidget()
|
||||
RecentPodcastsWidget()
|
||||
}
|
||||
}
|
||||
98
MarksWidget/RandomBookmarkWidget.swift
Normal file
98
MarksWidget/RandomBookmarkWidget.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
108
MarksWidget/RecentBookmarksWidget.swift
Normal file
108
MarksWidget/RecentBookmarksWidget.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
111
MarksWidget/RecentPodcastsWidget.swift
Normal file
111
MarksWidget/RecentPodcastsWidget.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
117
MarksWidget/WidgetDataStore.swift
Normal file
117
MarksWidget/WidgetDataStore.swift
Normal 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")!
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user