feat(macos): add a native SwiftUI client - #1041
Closed
YunFy26 wants to merge 15 commits into
Closed
Conversation
Add a SwiftUI macOS frontend that talks to the existing Go HTTP API, together with its launcher, universal .app packaging script, and CI jobs for building and testing it. Keep the detail pane's reported size independent of the article text so NavigationSplitView lays the window out around the window itself. A pane whose ideal size follows the header, the summary card, or the web content made the split view size itself to that content and pushed the sidebar and the article list outside the visible area. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split long input into chunks before sending it to the free Google endpoint, send a browser User-Agent, return the input unchanged when it already matches the target language, and read the endpoint setting from the database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # .github/workflows/test.yml # .gitignore # internal/translation/dynamic.go # internal/translation/translation_providers_test.go
A SwiftPM executable is not an application bundle, so AppKit launched it with the prohibited activation policy: no Dock icon, and no ownership of the menu bar, which left Command-V and Command-A without any effect in text fields even though the Edit menu was built. Switch to the regular activation policy at launch when the process was not started as a bundle, and load the repository icon so an unbundled run shows the real icon. Rename BackendManager to AppDelegate now that it also configures how the application presents itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sidebar drew its rows as plain buttons and marked the current one with listRowBackground, which the sidebar list style overrides. The underlying table view therefore held no selection at all and a click left no indication of which item was chosen. Drive the list from the view model's selection binding and tag each row, so the sidebar draws the standard selection and hover treatment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grouped form placed each label beside its field, which left the URL field 329 points wide with the label directly against it, so a long address scrolled out of view and read as if it ran into the label. The sheet's fixed height also cut the form off and made it scroll. Place each label above its field, give every field the full width of the sheet, and let the sheet size itself to its content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sidebar listed every subscription in one flat run, and each one used the same grey glyph, so a long list was hard to scan. Group feeds by the category already recorded on each feed, which the Vue frontend shares. Folders can be created, renamed and deleted from the sidebar, feeds move between them from the row's context menu, and selecting a folder lists the articles of every feed it holds. A folder that holds no feeds yet has nowhere to live on the server, so its name is kept on this Mac until a feed moves into it. Add /api/feeds/category so a move updates only the category. The existing update endpoint rewrites every field of a feed, which would discard proxy, XPath and mail settings that the macOS client does not model. Give each feed a stable colour drawn from a fourteen hue palette, keyed by a seed independent hash of its address so the colour survives relaunches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Creating a folder was only reachable from the toolbar menu or a feed's own context menu, so a right click on the space below the last row did nothing. SwiftUI's contextMenu attaches to rows only, and the sidebar's table view discards an assigned menu. AppKit then asks each ancestor in turn, so the menu goes on the enclosing clip and scroll views, which answer for the empty area while every row keeps its own menu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Filing a subscription meant going through the row's context menu. Make feed rows draggable and folder rows accept them, highlighting the folder and expanding it while a drag hovers over it. The drag carries the feed identifier under an identifier of our own rather than as text, so a drag from another application cannot be mistaken for a subscription. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dragging a subscription started, but no folder would take it. The dragged type came from UTType(exportedAs:), which carries its declared conformances only when the running bundle declares the type as well. An unbundled run therefore dragged a type that conforms to nothing, while every drop target registers the broad types it conforms to, so nothing ever matched and the drop was refused. Derive the type from a tag instead, which resolves to a type that conforms to public.data in any process, and carry the payload through onDrag and onDrop with that type on both sides. Dropping on the Feeds heading now takes a subscription back out of its folder, which had no pointer driven equivalent before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dragging a subscription over another one gave no feedback and had no effect, so the sidebar's order could not be changed by pointer at all. Each row now reports where the pointer sits inside it, draws a line along the edge the drop will land on, and files the subscription directly before or after that row. The rank goes to the existing reorder endpoint, so the order is the position the server records and is shared with the Vue frontend. Dropping onto a row inside a folder files the subscription into that folder at the same time. Sort feeds by that position when they load, which the sidebar did not do before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reordering drew a coloured bar along the edge a drop would land on, which reads as a foreign element in a sidebar and says little about the result. Follow the pointer with the arrangement itself: the travelling row is lifted out of where it came from and shown where it would land, the rows around it move aside, and the row it left behind dims. Hovering the travelling row changes nothing, which keeps the preview from oscillating as the rows move under the pointer. The drop commits what the preview was showing. A drag with no preview behind it, such as one begun elsewhere, still falls back to the row under the pointer. Folder and heading targets now tint with the system's own selection colour rather than one of our choosing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three separate causes, all on the client; the API answers in about a millisecond. Thumbnails went through AsyncImage, which decodes whatever the server sends at full resolution on the main thread and keeps nothing between appearances. Switching feeds started dozens of full sized decodes at once, which is what made a click on a subscription appear to do nothing for a moment. Decode to the drawn size through ImageIO instead and keep the results in memory. A drag reported its position many times a second into view state, so every row was rebuilt on each report. Hold that in a coordinator object instead, where it redraws nothing. Dragging past the top or bottom of the list did nothing, because a list only scrolls itself for its own drags. Follow the pointer past either edge while a drag is in flight, faster the closer it gets. Settle the rows with a calm spring rather than a snap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A SwiftUI List cannot reproduce what the system's own source lists do while something is dragged over them, so the previous version carried a hand written stand in for all of it: a drag preview that reordered the rows, a position report on every pointer move, edge scrolling driven by a timer, and a menu bolted onto the enclosing scroll view because the rows were the only things a context menu could reach. An outline view does all of that itself. The gap between rows, the folder that lights up underneath the pointer, the scrolling past either edge and the menu over the empty area are now the system's, and the rows are AppKit cells rather than hosted SwiftUI views, so a drag no longer rebuilds them. The toolbar, the status line, the sheets and the confirmations stay in SwiftUI, and the outline reports selection and drops back through a binding and a set of actions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SwiftUI client was documented only inside frontend-swift, so neither readme said it existed or how to start it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
A native SwiftUI client for macOS 14 or later, under
frontend-swift. It talks to the existing Go HTTP API and does not touch the Vue frontend or the Wails application.The client provides three column navigation, subscription management, folders, source refresh, article filtering and pagination, read and favourite actions, translation, summaries, automation rules, the full settings surface, a configurable server address, and restricted HTML rendering for untrusted feed content.
Also included is a launcher (
frontend-swift/run.sh), a script that produces a universal.appand DMG (frontend-swift/build-app.sh), and CI jobs that build and test the client and attach the DMG to releases.Sidebar
Subscriptions can be grouped into folders. A folder is the
categoryalready recorded on each feed, so folders are shared with the Vue frontend rather than being a separate concept. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.The sidebar is an
NSOutlineViewrather than a SwiftUIList, because aListcannot reproduce what the system's own source lists do while something is dragged over them. Dragging a subscription opens a gap between the rows where it would land, lights up a folder when the pointer rests on it, and follows the pointer past the top and bottom edges of a long list. The resulting order is written through the existing/api/feeds/reorderendpoint, so it is thepositionthe server records.Changes outside the client
POST /api/feeds/categorysets only a feed's category, calling the existingDB.UpdateFeedCategory, which had no HTTP route./api/feeds/updatewas not reused because it rewrites every field of a feed from the request body, which would discard proxy, XPath and mail settings that the macOS client does not model.internal/translation/google.go: the free Google provider now splits long input into chunks, sends a browser User-Agent, returns the input unchanged when it already matches the target language, and reads its endpoint setting from the database.Testing
swift test --package-path frontend-swift: 67 tests covering the view model, folders, ordering, the outline data source and its drag and drop, the sheet layouts, image downsampling and caching, and HTML sanitising.go test ./internal/...andgo build ./...pass;gofmtandgo vetare clean.🤖 Generated with Claude Code