Skip to content

feat(macos): add a native SwiftUI client - #1041

Closed
YunFy26 wants to merge 15 commits into
DevXDojo:mainfrom
YunFy26:codex/swiftui-macos
Closed

feat(macos): add a native SwiftUI client#1041
YunFy26 wants to merge 15 commits into
DevXDojo:mainfrom
YunFy26:codex/swiftui-macos

Conversation

@YunFy26

@YunFy26 YunFy26 commented Aug 25, 2026

Copy link
Copy Markdown

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 .app and 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 category already 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 NSOutlineView rather than a SwiftUI List, because a List cannot 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/reorder endpoint, so it is the position the server records.

Changes outside the client

  • POST /api/feeds/category sets only a feed's category, calling the existing DB.UpdateFeedCategory, which had no HTTP route. /api/feeds/update was 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.
  • Both readmes describe how to run and build the client.

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/... and go build ./... pass; gofmt and go vet are clean.
  • The client was run against a local server throughout, and the layout work was verified against the real article content in the database.

🤖 Generated with Claude Code

YunFy26 and others added 15 commits August 24, 2026 20:05
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>
@YunFy26 YunFy26 closed this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant