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

View File

@@ -7,8 +7,8 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; }; 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; };
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */; };
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
@@ -16,24 +16,42 @@
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; }; 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; }; 8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; }; 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; }; 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; }; AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; };
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; };
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
proxyType = 1;
remoteGlobalIDString = 61046BF39C48393DD96687A0;
remoteInfo = MarksWidget;
};
BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = { BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */; containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
@@ -51,6 +69,7 @@
dstSubfolderSpec = 13; dstSubfolderSpec = 13;
files = ( files = (
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */, B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */,
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */,
); );
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -59,9 +78,17 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; }; 03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
0552C13335034219DECF4F62 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; }; 07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; }; 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; };
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentBookmarksWidget.swift; sourceTree = "<group>"; };
55D369E6D2B8B036685BBD13 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; }; 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
@@ -71,6 +98,8 @@
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MarksWidget.entitlements; sourceTree = "<group>"; };
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = "<group>"; };
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -80,15 +109,36 @@
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; }; BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; }; CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; };
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; }; F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; }; F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
6B2547BA0E17C2564677B34A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A1E820C4B9604F8F05055D3E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */,
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
3952A081972B444674BEB51B /* Marks */ = { 3952A081972B444674BEB51B /* Marks */ = {
isa = PBXGroup; isa = PBXGroup;
@@ -127,8 +177,8 @@
7ABBFDF43A1EB773E0A49CFE /* Services */ = { 7ABBFDF43A1EB773E0A49CFE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */, 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
@@ -136,10 +186,25 @@
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
85E717A515682EBF67DD199A /* MarksWidget */ = {
isa = PBXGroup;
children = (
55D369E6D2B8B036685BBD13 /* Info.plist */,
969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */,
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */,
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */,
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */,
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */,
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */,
);
path = MarksWidget;
sourceTree = "<group>";
};
AF9CABF75E77168EF1D60F29 /* Views */ = { AF9CABF75E77168EF1D60F29 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
@@ -159,7 +224,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3952A081972B444674BEB51B /* Marks */, 3952A081972B444674BEB51B /* Marks */,
85E717A515682EBF67DD199A /* MarksWidget */,
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */, 7A85D09B4E109E1D9CD8F5BB /* ShareExtension */,
F5B3C85424137FA0CE2467B1 /* Frameworks */,
C6978BB2E0A71E9A349D5EB6 /* Products */, C6978BB2E0A71E9A349D5EB6 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -168,26 +235,56 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AB6C53AB14A38FCD4CC7628D /* Marks.app */, AB6C53AB14A38FCD4CC7628D /* Marks.app */,
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */,
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */, 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
F5B3C85424137FA0CE2467B1 /* Frameworks */ = {
isa = PBXGroup;
children = (
093402014727A338745A06DA /* SwiftUI.framework */,
0552C13335034219DECF4F62 /* WidgetKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
61046BF39C48393DD96687A0 /* MarksWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */;
buildPhases = (
2B3C202ACE4B7248E24601BF /* Sources */,
A1E820C4B9604F8F05055D3E /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = MarksWidget;
packageProductDependencies = (
);
productName = MarksWidget;
productReference = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
76C272776D8EBCCD3FB6715A /* Marks */ = { 76C272776D8EBCCD3FB6715A /* Marks */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */; buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */;
buildPhases = ( buildPhases = (
81AF93AC59791A6311B9055B /* Sources */, 81AF93AC59791A6311B9055B /* Sources */,
D56AC7DEA48961F856A6CEF6 /* Resources */, D56AC7DEA48961F856A6CEF6 /* Resources */,
6B2547BA0E17C2564677B34A /* Frameworks */,
93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */, 93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */, A87C510E52F149E7E3CA0283 /* PBXTargetDependency */,
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */,
); );
name = Marks; name = Marks;
packageProductDependencies = ( packageProductDependencies = (
@@ -222,6 +319,10 @@
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1630; LastUpgradeCheck = 1630;
TargetAttributes = { TargetAttributes = {
61046BF39C48393DD96687A0 = {
DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic;
};
76C272776D8EBCCD3FB6715A = { 76C272776D8EBCCD3FB6715A = {
DevelopmentTeam = AE5DZKJHGN; DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
@@ -247,6 +348,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
76C272776D8EBCCD3FB6715A /* Marks */, 76C272776D8EBCCD3FB6715A /* Marks */,
61046BF39C48393DD96687A0 /* MarksWidget */,
CF31B2655F8F30CF6DF3C26F /* ShareExtension */, CF31B2655F8F30CF6DF3C26F /* ShareExtension */,
); );
}; };
@@ -272,18 +374,31 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
2B3C202ACE4B7248E24601BF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */,
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */,
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */,
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */,
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
81AF93AC59791A6311B9055B /* Sources */ = { 81AF93AC59791A6311B9055B /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */, 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */, BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */, C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
@@ -297,12 +412,18 @@
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */, B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */, 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 61046BF39C48393DD96687A0 /* MarksWidget */;
targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */;
};
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = { A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */; target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */;
@@ -422,6 +543,23 @@
}; };
name = Release; name = Release;
}; };
6CDF13C33FDD1141CFE677AD /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
INFOPLIST_FILE = MarksWidget/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
7AD80830605CAD3492E03039 /* Debug */ = { 7AD80830605CAD3492E03039 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -506,6 +644,23 @@
}; };
name = Debug; name = Debug;
}; };
D8AB62E7173CC10C6EB15373 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
INFOPLIST_FILE = MarksWidget/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -536,6 +691,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6CDF13C33FDD1141CFE677AD /* Debug */,
D8AB62E7173CC10C6EB15373 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */; rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */;

View File

@@ -21,6 +21,20 @@
ReferencedContainer = "container:Marks.xcodeproj"> ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "61046BF39C48393DD96687A0"
BuildableName = "MarksWidget.appex"
BlueprintName = "MarksWidget"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
buildForRunning = "YES" buildForRunning = "YES"

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "61046BF39C48393DD96687A0"
BuildableName = "MarksWidget.appex"
BlueprintName = "MarksWidget"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -28,5 +28,14 @@
<array> <array>
<string>audio</string> <string>audio</string>
</array> </array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>marks</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -18,16 +18,24 @@ struct MarksApp: App {
defaultConfig.save() defaultConfig.save()
serverConfig = defaultConfig serverConfig = defaultConfig
} }
.task { serverConfig.save() }
} }
} }
} }
private struct IdentifiableURL: Identifiable {
let id = UUID()
let url: URL
}
// Holds the API + ViewModel so they survive re-renders // Holds the API + ViewModel so they survive re-renders
struct MainContainer: View { struct MainContainer: View {
let config: ServerConfig let config: ServerConfig
let onDisconnect: () -> Void let onDisconnect: () -> Void
@State private var viewModel: BookmarksViewModel @State private var viewModel: BookmarksViewModel
@State private var deepLinkBrowser: IdentifiableURL?
@State private var showDeepLinkPlayer = false
init(config: ServerConfig, onDisconnect: @escaping () -> Void) { init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
self.config = config self.config = config
@@ -48,5 +56,45 @@ struct MainContainer: View {
SearchView(viewModel: viewModel) SearchView(viewModel: viewModel)
} }
} }
.onOpenURL { url in
handleDeepLink(url)
}
.sheet(item: $deepLinkBrowser) { item in
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer)
}
.sheet(isPresented: $showDeepLinkPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
}
private func handleDeepLink(_ url: URL) {
guard url.scheme == "marks",
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value
else { return }
switch url.host {
case "bookmark":
if let browserURL = URL(string: articleUrl) {
deepLinkBrowser = IdentifiableURL(url: browserURL)
}
case "podcast":
let podcasts = WidgetDataStore.loadPodcasts()
let found = podcasts.first { $0.articleUrl == articleUrl }
viewModel.podcastPlayer.start(
articleUrl: articleUrl,
articleTitle: found?.title ?? "",
claude: viewModel.claude
)
showDeepLinkPlayer = true
default:
break
}
} }
} }

View File

@@ -67,13 +67,13 @@ struct ClaudeService: Sendable {
return try JSONDecoder().decode(PodcastStatus.self, from: data) return try JSONDecoder().decode(PodcastStatus.self, from: data)
} }
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil) async throws -> URL { func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil, parentBookmarkUrl: String? = nil) async throws -> URL {
let dest = ClaudeService.cachedPodcastURL(for: articleUrl) let dest = ClaudeService.cachedPodcastURL(for: articleUrl)
try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(), try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
withIntermediateDirectories: true) withIntermediateDirectories: true)
let data = try await get("/v1/podcast/audio/\(jobId)") let data = try await get("/v1/podcast/audio/\(jobId)")
try data.write(to: dest, options: .atomic) try data.write(to: dest, options: .atomic)
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title) PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title, parentBookmarkUrl: parentBookmarkUrl)
return dest return dest
} }

View File

@@ -6,6 +6,7 @@ struct PodcastEntry: Codable, Identifiable {
let filename: String let filename: String
var title: String? var title: String?
let createdAt: Date let createdAt: Date
var parentBookmarkUrl: String?
} }
enum PodcastIndex { enum PodcastIndex {
@@ -35,14 +36,19 @@ enum PodcastIndex {
return entries.sorted { $0.createdAt > $1.createdAt } return entries.sorted { $0.createdAt > $1.createdAt }
} }
static func upsert(articleUrl: String, filename: String, title: String?) { static func upsert(articleUrl: String, filename: String, title: String?, parentBookmarkUrl: String? = nil) {
var entries = all() var entries = all()
entries.removeAll { $0.articleUrl == articleUrl } entries.removeAll { $0.articleUrl == articleUrl }
entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename, entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename,
title: title, createdAt: Date()), at: 0) title: title, createdAt: Date(),
parentBookmarkUrl: parentBookmarkUrl), at: 0)
save(entries) save(entries)
} }
static func find(for bookmarkUrl: String) -> [PodcastEntry] {
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
}
static func remove(articleUrl: String) { static func remove(articleUrl: String) {
var entries = all() var entries = all()
entries.removeAll { $0.articleUrl == articleUrl } entries.removeAll { $0.articleUrl == articleUrl }
@@ -52,5 +58,8 @@ enum PodcastIndex {
private static func save(_ entries: [PodcastEntry]) { private static func save(_ entries: [PodcastEntry]) {
guard let data = try? encoder.encode(entries) else { return } guard let data = try? encoder.encode(entries) else { return }
try? data.write(to: indexURL, options: .atomic) try? data.write(to: indexURL, options: .atomic)
WidgetDataStore.savePodcasts(entries.prefix(10).map {
WidgetPodcast(articleUrl: $0.articleUrl, title: $0.title ?? $0.articleUrl, createdAt: $0.createdAt)
})
} }
} }

View File

@@ -0,0 +1,111 @@
import SwiftUI
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
struct BookmarkListRow: View {
let bookmark: Bookmark
let viewModel: BookmarksViewModel
var readingProgress: Double = 0
let onOpen: () -> Void
var onEdit: (() -> Void)? = nil
@Environment(\.openURL) private var openURL
@State private var showFullPlayer = false
@State private var episodePickerBookmark: Bookmark?
var body: some View {
Button { onOpen() } label: {
BookmarkRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
}
.buttonStyle(RowPressStyle())
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
if let onEdit {
Button { onEdit() } label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
.contextMenu {
if let onEdit {
Button { onEdit() } label: {
Label("Edit", systemImage: "pencil")
}
}
Button { onOpen() } label: {
Label("Open", systemImage: "globe")
}
Button {
if let url = URL(string: bookmark.url) { openURL(url) }
} label: {
Label("Open in Safari", systemImage: "safari")
}
Button { handlePodcast() } label: {
Label("Convert to Podcast", systemImage: "headphones")
}
Divider()
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
.sheet(item: $episodePickerBookmark) { b in
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
}
private func handlePodcast() {
let episodes = PodcastIndex.find(for: bookmark.url)
if episodes.count >= 2 {
episodePickerBookmark = bookmark
} else if let ep = episodes.first {
viewModel.podcastPlayer.start(
articleUrl: ep.articleUrl,
articleTitle: ep.title ?? bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
} else {
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
}
}
}

View File

@@ -1,10 +1,57 @@
import SwiftUI import SwiftUI
// MARK: - Skeleton loading row
private struct SkeletonRow: View {
var delay: Double = 0
@State private var dimmed = false
var body: some View {
HStack(alignment: .top, spacing: 11) {
RoundedRectangle(cornerRadius: 7)
.fill(Color(.systemGray5))
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 7) {
Capsule()
.fill(Color(.systemGray5))
.frame(maxWidth: .infinity)
.frame(height: 13)
Capsule()
.fill(Color(.systemGray6))
.frame(width: 140, height: 10)
}
.padding(.top, 4)
Spacer()
}
.padding(.vertical, 14)
.opacity(dimmed ? 0.45 : 1)
.onAppear {
withAnimation(
.easeInOut(duration: 0.9)
.repeatForever(autoreverses: true)
.delay(delay)
) { dimmed = true }
}
}
}
// MARK: - Row press ButtonStyle
struct RowPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.opacity(configuration.isPressed ? 0.75 : 1)
.animation(.spring(duration: 0.18, bounce: 0), value: configuration.isPressed)
}
}
// MARK: - BookmarksView
struct BookmarksView: View { struct BookmarksView: View {
@Bindable var viewModel: BookmarksViewModel @Bindable var viewModel: BookmarksViewModel
let onDisconnect: () -> Void let onDisconnect: () -> Void
@Environment(\.openURL) private var openURL
@State private var showSettings = false @State private var showSettings = false
@State private var showCollections = false @State private var showCollections = false
@State private var showAddBookmark = false @State private var showAddBookmark = false
@@ -17,84 +64,23 @@ struct BookmarksView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
ForEach(viewModel.bookmarks) { bookmark in if viewModel.isLoading && viewModel.bookmarks.isEmpty {
BookmarkRow( ForEach(0..<3, id: \.self) { i in
bookmark: bookmark, SkeletonRow(delay: Double(i) * 0.13)
readingProgress: readingProgress[bookmark.url] ?? 0, .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
onPodcast: { .listRowSeparator(.visible)
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
}
)
.onTapGesture {
browsingBookmark = bookmark
} }
} else {
ForEach(viewModel.bookmarks) { bookmark in
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0,
onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark }
)
.onAppear { maybeLoadMore(bookmark) } .onAppear { maybeLoadMore(bookmark) }
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) }
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
Button {
editingBookmark = bookmark
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
.contextMenu {
Button {
editingBookmark = bookmark
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
browsingBookmark = bookmark
} label: {
Label("Open", systemImage: "globe")
}
Button {
if let url = URL(string: bookmark.url) { openURL(url) }
} label: {
Label("Open in Safari", systemImage: "safari")
}
Button {
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
} label: {
Label("Convert to Podcast", systemImage: "headphones")
}
Divider()
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
} }
if viewModel.isLoadingMore { if viewModel.isLoadingMore {
@@ -103,6 +89,7 @@ struct BookmarksView: View {
} }
} }
.listStyle(.plain) .listStyle(.plain)
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks") .navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.toolbar { .toolbar {
@@ -139,6 +126,7 @@ struct BookmarksView: View {
Image(systemName: viewModel.unreadFilter Image(systemName: viewModel.unreadFilter
? "line.3.horizontal.decrease.circle.fill" ? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle") : "line.3.horizontal.decrease.circle")
.contentTransition(.symbolEffect(.replace))
} }
Button { showSettings = true } label: { Button { showSettings = true } label: {
Image(systemName: "gearshape") Image(systemName: "gearshape")
@@ -147,11 +135,13 @@ struct BookmarksView: View {
} }
} }
.overlay { .overlay {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ProgressView()
}
if !viewModel.isLoading && viewModel.bookmarks.isEmpty { if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here.")) ContentUnavailableView(
"No Bookmarks",
systemImage: "bookmark",
description: Text("Bookmarks you save will appear here.")
)
.transition(.opacity)
} }
} }
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
@@ -169,6 +159,7 @@ struct BookmarksView: View {
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty) .animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
.padding(.bottom, 8) .padding(.bottom, 8)
} }
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
} }
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect) SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
@@ -184,7 +175,9 @@ struct BookmarksView: View {
} }
.sheet(item: $browsingBookmark) { bookmark in .sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) { if let url = URL(string: bookmark.url) {
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude) BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) {
await viewModel.archive(bookmark)
}
} }
} }
.onChange(of: browsingBookmark) { _, new in .onChange(of: browsingBookmark) { _, new in

View File

@@ -39,6 +39,9 @@ final class BookmarksViewModel {
nextPageUrl = response.next nextPageUrl = response.next
restoreAIData() restoreAIData()
saveToCache(bookmarks) saveToCache(bookmarks)
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
})
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }

View File

@@ -25,6 +25,29 @@ enum ReadingProgress {
} }
} }
// MARK: - Indeterminate page load bar
private struct PageLoadBar: View {
@State private var phase: CGFloat = 0
var body: some View {
GeometryReader { geo in
let barWidth = geo.size.width * 0.38
Capsule()
.fill(.blue.opacity(0.75))
.frame(width: barWidth, height: 3)
.offset(x: (phase * (geo.size.width + barWidth)) - barWidth)
}
.frame(height: 3)
.clipped()
.onAppear {
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
// MARK: - BrowserState // MARK: - BrowserState
@Observable @Observable
@@ -196,18 +219,25 @@ struct BrowserView: View {
let initialURL: URL let initialURL: URL
let initialTitle: String let initialTitle: String
let claude: ClaudeService let claude: ClaudeService
let podcastPlayer: PodcastPlayerViewModel
var onArchive: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@State private var state: BrowserState @State private var state: BrowserState
@State private var readerMode = false @State private var readerMode = false
@State private var showPodcast = false @State private var showPodcast = false
@State private var podcastVM = PodcastPlayerViewModel() @State private var archiveTapCount = 0
@State private var navBackCount = 0
@State private var navForwardCount = 0
@State private var toolbarHidden = false
init(url: URL, title: String, claude: ClaudeService) { init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) {
self.initialURL = url self.initialURL = url
self.initialTitle = title self.initialTitle = title
self.claude = claude self.claude = claude
self.podcastPlayer = podcastPlayer
self.onArchive = onArchive
self._state = State(initialValue: BrowserState(url: url)) self._state = State(initialValue: BrowserState(url: url))
} }
@@ -221,16 +251,17 @@ struct BrowserView: View {
if state.readingProgress > 0.01 && state.readingProgress < 0.99 { if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
GeometryReader { geo in GeometryReader { geo in
Rectangle() Rectangle()
.fill(Color.blue.opacity(0.7)) .fill(Color.blue.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3) .frame(width: geo.size.width * state.readingProgress, height: 3)
} }
.frame(height: 3) .frame(height: 3)
.animation(.linear(duration: 0.1), value: state.readingProgress) .animation(.linear(duration: 0.1), value: state.readingProgress)
} }
// Page loading sweep bar (replaces center spinner)
if state.isLoading { if state.isLoading {
ProgressView() PageLoadBar()
.padding(.top, 8) .transition(.opacity)
} }
} }
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle) .navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
@@ -247,44 +278,28 @@ struct BrowserView: View {
Image(systemName: "safari") Image(systemName: "safari")
} }
} }
ToolbarItemGroup(placement: .bottomBar) { }
Button { state.webView.goBack() } label: { }
Image(systemName: "chevron.left") .safeAreaInset(edge: .bottom, spacing: 0) {
} bottomBar
.disabled(!state.canGoBack) .offset(y: toolbarHidden ? 88 : 0)
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
Spacer() }
.onChange(of: state.readingProgress) { old, new in
Button { state.webView.goForward() } label: { let delta = new - old
Image(systemName: "chevron.right") withAnimation(.spring(duration: 0.3, bounce: 0)) {
} if delta > 0.012 && new > 0.07 {
.disabled(!state.canGoForward) toolbarHidden = true
} else if delta < -0.005 || new < 0.05 {
Spacer() toolbarHidden = false
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
}
.disabled(state.isLoading)
Spacer()
Button { showPodcast = true } label: {
Image(systemName: "headphones")
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
}
} }
} }
} }
.onChange(of: state.isLoading) { _, loading in
if loading {
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
}
}
.onDisappear { .onDisappear {
let urlStr = (state.currentURL ?? initialURL).absoluteString let urlStr = (state.currentURL ?? initialURL).absoluteString
let p = state.readingProgress let p = state.readingProgress
@@ -292,12 +307,101 @@ struct BrowserView: View {
} }
.sheet(isPresented: $showPodcast) { .sheet(isPresented: $showPodcast) {
PodcastPlayerView( PodcastPlayerView(
vm: podcastVM, vm: podcastPlayer,
articleUrl: (state.currentURL ?? initialURL).absoluteString, articleUrl: (state.currentURL ?? initialURL).absoluteString,
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle, articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
claude: claude, claude: claude,
stopOnDismiss: true stopOnDismiss: false
) )
} }
} }
private var bottomBar: some View {
HStack {
Button {
navBackCount += 1
state.webView.goBack()
} label: {
Image(systemName: "chevron.left")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoBack)
.sensoryFeedback(.impact(weight: .light), trigger: navBackCount)
Spacer()
Button {
navForwardCount += 1
state.webView.goForward()
} label: {
Image(systemName: "chevron.right")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoForward)
.sensoryFeedback(.impact(weight: .light), trigger: navForwardCount)
Spacer()
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
.contentTransition(.symbolEffect(.replace))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
if onArchive != nil { Spacer() }
if let onArchive {
Button {
archiveTapCount += 1
Task { await onArchive() }
dismiss()
} label: {
Image(systemName: "archivebox")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount)
}
Spacer()
Button {
let currentUrl = (state.currentURL ?? initialURL).absoluteString
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
podcastPlayer.start(
articleUrl: currentUrl,
articleTitle: currentTitle,
claude: claude,
parentBookmarkUrl: initialURL.absoluteString
)
showPodcast = true
} label: {
Image(systemName: "headphones")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
.font(.system(size: 17))
.foregroundStyle(.primary)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.bar)
.overlay(alignment: .top) { Divider() }
}
} }

View File

@@ -59,7 +59,7 @@ final class PodcastPlayerViewModel {
private var interruptionObserver: Any? private var interruptionObserver: Any?
private var currentPodcastTitle: String = "Marks Podcast" private var currentPodcastTitle: String = "Marks Podcast"
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) { func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) {
// Idempotent don't restart if already generating or playing this article // Idempotent don't restart if already generating or playing this article
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return } if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
@@ -90,7 +90,7 @@ final class PodcastPlayerViewModel {
) )
if job.isDone { if job.isDone {
phase = .downloading phase = .downloading
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title) let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title, parentBookmarkUrl: parentBookmarkUrl)
setupPlayer(url: audioUrl, title: job.title ?? "Podcast") setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
return return
} }
@@ -674,3 +674,72 @@ struct PodcastLibraryView: View {
if vm.currentArticleUrl == entry.articleUrl { vm.stop() } if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
} }
} }
// MARK: - Episode picker (multiple episodes per bookmark)
struct EpisodePickerView: View {
let bookmark: Bookmark
let vm: PodcastPlayerViewModel
let claude: ClaudeService
@State private var entries: [PodcastEntry] = []
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(entries) { entry in
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.lineLimit(2)
if entry.articleUrl != bookmark.url {
Text(entry.articleUrl)
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
Button {
vm.start(articleUrl: entry.articleUrl,
articleTitle: entry.title ?? bookmark.displayTitle,
claude: claude)
dismiss()
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { deleteEntry(entry) } label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.navigationTitle("Episodes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.onAppear { entries = PodcastIndex.find(for: bookmark.url) }
}
private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
}
}

View File

@@ -2,30 +2,22 @@ import SwiftUI
struct SearchView: View { struct SearchView: View {
@Bindable var viewModel: BookmarksViewModel @Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var searchText = "" @State private var searchText = ""
@State private var useSemanticSearch = false @State private var useSemanticSearch = false
@State private var semanticResults: [Bookmark]? = nil @State private var semanticResults: [Bookmark]? = nil
@State private var searchTask: Task<Void, Never>? @State private var searchTask: Task<Void, Never>?
@State private var browsingBookmark: Bookmark?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
ForEach(results) { bookmark in ForEach(results) { bookmark in
BookmarkRow(bookmark: bookmark) BookmarkListRow(
.onTapGesture { bookmark: bookmark,
if let url = URL(string: bookmark.url) { openURL(url) } viewModel: viewModel,
} onOpen: { browsingBookmark = bookmark }
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) )
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
} }
} }
.listStyle(.plain) .listStyle(.plain)
@@ -49,6 +41,19 @@ struct SearchView: View {
ProgressView() ProgressView()
} }
} }
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(
url: url,
title: bookmark.displayTitle,
claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer
) {
await viewModel.archive(bookmark)
}
}
}
} }
.searchable(text: $searchText, prompt: "Titles, URLs, tags…") .searchable(text: $searchText, prompt: "Titles, URLs, tags…")
.onChange(of: searchText) { _, _ in scheduleSearch() } .onChange(of: searchText) { _, _ in scheduleSearch() }

View File

@@ -48,32 +48,17 @@ struct TagsView: View {
struct TagBookmarksView: View { struct TagBookmarksView: View {
let tag: String let tag: String
@Bindable var viewModel: BookmarksViewModel @Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var browsingBookmark: Bookmark?
var body: some View { var body: some View {
List { List {
ForEach(filteredBookmarks) { bookmark in ForEach(filteredBookmarks) { bookmark in
BookmarkRow(bookmark: bookmark) BookmarkListRow(
.onTapGesture { bookmark: bookmark,
if let url = URL(string: bookmark.url) { openURL(url) } viewModel: viewModel,
} onOpen: { browsingBookmark = bookmark }
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) )
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
}
} }
} }
.listStyle(.plain) .listStyle(.plain)
@@ -84,6 +69,19 @@ struct TagBookmarksView: View {
ContentUnavailableView("No Bookmarks", systemImage: "tag") ContentUnavailableView("No Bookmarks", systemImage: "tag")
} }
} }
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(
url: url,
title: bookmark.displayTitle,
claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer
) {
await viewModel.archive(bookmark)
}
}
}
} }
private var filteredBookmarks: [Bookmark] { private var filteredBookmarks: [Bookmark] {

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")!
}
}

View File

@@ -21,6 +21,7 @@ schemes:
targets: targets:
Marks: all Marks: all
ShareExtension: all ShareExtension: all
MarksWidget: all
run: run:
config: Debug config: Debug
test: test:
@@ -32,12 +33,37 @@ schemes:
archive: archive:
config: Release config: Release
MarksWidget:
build:
targets:
Marks: all
MarksWidget: all
run:
config: Debug
analyze:
config: Debug
archive:
config: Release
ShareExtension:
build:
targets:
Marks: all
ShareExtension: all
run:
config: Debug
analyze:
config: Debug
archive:
config: Release
targets: targets:
Marks: Marks:
type: application type: application
platform: iOS platform: iOS
sources: sources:
- path: Marks - path: Marks
- path: MarksWidget/WidgetDataStore.swift
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks
@@ -46,6 +72,8 @@ targets:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies: dependencies:
- target: ShareExtension - target: ShareExtension
- target: MarksWidget
- sdk: WidgetKit.framework
ShareExtension: ShareExtension:
type: app-extension type: app-extension
@@ -59,3 +87,18 @@ targets:
CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements
SKIP_INSTALL: YES SKIP_INSTALL: YES
APPLICATION_EXTENSION_API_ONLY: YES APPLICATION_EXTENSION_API_ONLY: YES
MarksWidget:
type: app-extension
platform: iOS
sources:
- path: MarksWidget
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.MarksWidget
INFOPLIST_FILE: MarksWidget/Info.plist
CODE_SIGN_ENTITLEMENTS: MarksWidget/MarksWidget.entitlements
SKIP_INSTALL: YES
dependencies:
- sdk: WidgetKit.framework
- sdk: SwiftUI.framework