Compare commits

...

17 Commits

Author SHA1 Message Date
github-actions[bot]
c95f7dcbb9 Bump build number to 266 2026-07-19 18:21:31 +00:00
Arkadiusz Fal
c38f82062f Enable macOS TestFlight build by default in release workflow 2026-07-19 19:42:18 +02:00
Arkadiusz Fal
6e36499f58 Update CHANGELOG 2026-07-19 19:41:00 +02:00
Arkadiusz Fal
f275499c7b Fix macOS legacy import sheet missing grouped form style 2026-07-19 19:35:26 +02:00
Arkadiusz Fal
283c2cb9cb Fix tvOS legacy import rows acting as a single Remove button
On tvOS a Form row is one focusable unit, so the legacy account/source
cards - header, credential fields, and both buttons stacked in a single
row - focused as a whole and every click fired the first button (Remove),
making credentials and Import unreachable.

- Split the tvOS rows into individually focusable rows (info header,
  fields, Import, Remove), keeping the card layout on iOS/macOS
- Wrap the tvOS entry points in TVSidebarDetailContainer and drop the
  tvOS navigationTitle that rendered as a ghost behind the form
- Present the review screen from the first-launch alert as a
  fullScreenCover on tvOS: sheets render as a small transparent card
  that cannot fit the sidebar layout (Menu button dismisses the cover)
- Dim disabled TVSettingsButtonStyle buttons
- Widen the row icon frame for tvOS glyph sizes and skip the caption
  when it would repeat the host shown as the title
2026-07-19 19:23:36 +02:00
Arkadiusz Fal
9c97036684 Make Enable Logging the master switch over all verbose logging
The verbose toggles (MPV, remote control) kept working with the master
"Enable Logging" toggle off: the settings UI hides them, but their
stored values - verboseMPVLogging is even iCloud-synced - were still
honored at runtime, producing console output and enabling the periodic
MPV stats collection while logging appeared disabled.

Enable Logging now takes precedence everywhere:

- LoggingService gates its DEBUG-build OSLog console output on the
  master toggle (it previously ran before the isEnabled guard, so the
  Xcode console was never silent)
- MPVLogging requires loggingEnabled && verboseMPVLogging, which also
  gates the 10s playback stats task via MPVLogging.verboseEnabled
- rcDebug in LocalNetworkService and RemoteControlCoordinator requires
  the master toggle alongside verboseRemoteControlLogging
2026-07-19 01:00:01 +02:00
Arkadiusz Fal
6aa5fd8ee7 Fix periodic tvOS playback stutter from render stalls on mpvQueue
Build 264 stutters for a split second every ~10 seconds. The periodic
playback-stats task fetches ~30 debug properties in one ~190ms block on
mpvQueue, and MPVClient.render() wrapped mpv_render_context_render in
mpvQueue.sync - so the render thread queued behind the fetch and stalled
~190ms, at exactly the stats interval. Build 261 predates the stats
task, which is why it was unaffected.

libmpv's render API is documented thread-safe relative to the client
API, so render-context calls no longer go through mpvQueue:

- New renderContextLock protects renderContext lifecycle vs use;
  render(), renderWithDepth(), renderSoftware(), reportSwap(),
  shouldRenderUpdateFrame() and hasRenderContext use it
- Render-update callbacks dispatch on a dedicated renderEventQueue so
  frame-ready notifications can't queue behind property/command work
- destroyRenderContext takes mpvQueue then the lock, still excluding
  in-flight renders during teardown
- The stats task skips its property fetch on iOS/tvOS unless verbose
  MPV logging is on (macOS keeps it for the render watchdog)
- performRender slow-frame warnings now break down render vs present
  time and report frame-delivery gaps for future triage
2026-07-19 00:53:06 +02:00
Arkadiusz Fal
c3bb01e862 Remove old CLAUDE.md 2026-07-18 19:01:22 +02:00
Arkadiusz Fal
94e3c25b37 Stop attaching the TestFlight pkg to GitHub releases
The macOS pkg is App Store-signed (Sparkle stripped, not notarized) and
only exists for the TestFlight upload — installing it directly produces
an app that will not launch. Users should get the dmg or zip.
2026-07-18 18:54:34 +02:00
Arkadiusz Fal
658b1e827e Update AltStore source for 2.0.0 (264) 2026-07-18 18:40:09 +02:00
Arkadiusz Fal
02707ddd6a Fix AltStore source push rejected by branch protection
The update_altstore job pushed with the default GITHUB_TOKEN, which
cannot bypass the changes-through-PR ruleset on main. Check out with
REPO_TOKEN like the release and appcast jobs, and forward secrets to
the reusable workflow with secrets: inherit.
2026-07-18 18:40:09 +02:00
github-actions[bot]
447f593950 Bump build number to 264 2026-07-18 16:35:47 +00:00
Arkadiusz Fal
90a0c6fd1a Fix doubled fastlane/ prefix in notarize lane shell paths
fastlane's sh runs from the fastlane/ directory, so the stapler/ditto/
hdiutil commands resolved fastlane/builds/... to fastlane/fastlane/
builds/... and failed after successful notarization.
2026-07-18 18:10:48 +02:00
Arkadiusz Fal
f3f45fdfdc Fix mac notarized build archiving wrong platform
build_mac_app in the build_and_notarize lane was missing the sdk and
destination overrides needed for the multiplatform scheme, so gym
archived for tvOS and failed on provisioning profiles. Mirror the
working mac beta lane.
2026-07-18 18:00:57 +02:00
Arkadiusz Fal
a673968f1e Fix build error with confirmationDialog on CI Xcode
Replace the item-based confirmationDialog overload, which is unavailable
in the Xcode 26.5 SDK used on CI, with the isPresented/presenting
variant.
2026-07-18 17:54:49 +02:00
Arkadiusz Fal
0a91182089 Merge branch 'rewrite/v2' 2026-07-18 17:48:53 +02:00
Arkadiusz Fal
c0815353d7 Create CNAME 2026-04-23 15:45:49 +02:00
19 changed files with 366 additions and 345 deletions

View File

@@ -13,7 +13,7 @@ on:
build_mac_beta:
description: 'Build macOS (TestFlight)'
type: boolean
default: false
default: true
build_mac_notarized:
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
type: boolean
@@ -237,7 +237,9 @@ jobs:
fi
- uses: ncipollo/release-action@v1
with:
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg,artifacts/**/*.dmg
# No .pkg here on purpose: the mac TestFlight pkg is App Store-signed
# (no Sparkle, not notarized) and would not run if installed directly.
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.dmg
commit: ${{ github.ref_name }}
tag: ${{ steps.compute_tag.outputs.tag }}
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
@@ -349,5 +351,6 @@ jobs:
if: ${{ inputs.build_ios && success() }}
needs: [release]
uses: ./.github/workflows/update-altstore.yml
secrets: inherit
with:
tag: ${{ needs.release.outputs.tag }}

View File

@@ -22,6 +22,9 @@ jobs:
- uses: actions/checkout@v4
with:
ref: main
# Default GITHUB_TOKEN cannot bypass the "changes through PR only"
# branch ruleset; REPO_TOKEN can (same as the release workflow).
token: ${{ secrets.REPO_TOKEN }}
- name: Get version info from release
run: |
TAG="${{ inputs.tag }}"

View File

@@ -1,44 +1,13 @@
## What's Changed
**macOS support** — Yattee 2 arrives on the Mac with its first public beta. It brings the full Yattee 2 experience to the desktop with a native interface: a dedicated player window with fullscreen and stay on top option, Picture in Picture, customizable and movable player controls, keyboard shortcuts, and other features present in the iOS app.
### Bug Fixes
The macOS app is available via [TestFlight](https://yattee.stream/beta2), or as a notarized direct download from [GitHub Releases](https://github.com/yattee/yattee/releases) that keeps itself up to date with built-in automatic updates.
* Fix macOS legacy import sheet missing grouped form style
* Fix tvOS legacy import rows acting as a single Remove button
* Fix periodic tvOS playback stutter from render stalls on mpvQueue
### General
### Other
#### New Features
* Add audio-only music mode, available in video player settings and player controls button
* Add custom accent color setting with system color picker and separate light and dark mode colors
* Redesign Home shortcut cards with layout, color, and palette options and a live style preview
* Add Edit Shortcuts and Hide options to Home shortcut context menus
* Add pause, resume, and cancel context menu to download rows
* Add manual legacy account import and allow importing account-less legacy instances as sources
#### Improvements
* Rework iCloud sync engine for more reliable syncing; resume sync from saved state instead of re-fetching everything
* Sync watch progress when a video plays to the end and when the app goes to the background
* Fall back to lower-quality thumbnails when higher-resolution variants are unavailable
* Apply the selected theme at the window level so it takes effect everywhere
#### Bug Fixes
* Fix disabling Background Playback having no effect
* Fix downloads never finishing and a crash on download completion
* Fix missing storyboards when advancing to the next queued video
* Fix live videos shown in the mini player bar
* Fix crash when opening the share sheet on iPad
* Fix rare data loss when the app is suspended in the background
* Fix iCloud sync conflicts for recent channels, playlists, and watch progress
* Fix Sources home shortcut counting disabled instances
### iOS
* Use toolbar search placement on iPad channel view
### tvOS
* Add A/V sync diagnostics settings page
* Default Channels grid to 5 columns
* Fixes for some reported playback issues
* Make Enable Logging the master switch over all verbose logging

162
CLAUDE.md
View File

@@ -1,162 +0,0 @@
# Yattee Development Notes
## Testing Instances
- **Invidious**: `https://invidious.home.arekf.net/` - Use this instance for testing API calls
- **Yattee Server**: `https://main.s.yattee.stream` - Local self-hosted Yattee server for backend testing
## Related Projects
### Yattee Server
Location: `~/Developer/yattee-server`
A self-hosted API server powered by yt-dlp that provides an Invidious-compatible API for YouTube content. Used as an alternative backend when Invidious/Piped instances are blocked or unavailable.
**Key features:**
- Invidious-compatible API endpoints (`/api/v1/videos`, `/api/v1/channels`, `/api/v1/search`, etc.)
- Uses yt-dlp with deno for YouTube JS challenge solving
- Returns direct YouTube CDN stream URLs
- Optional backing Invidious instance for trending, popular, and search suggestions
**API endpoints:**
- `GET /api/v1/videos/{video_id}` - Video metadata and streams
- `GET /api/v1/channels/{channel_id}` - Channel info
- `GET /api/v1/channels/{channel_id}/videos` - Channel videos
- `GET /api/v1/search?q={query}` - Search
- `GET /api/v1/playlists/{playlist_id}` - Playlist info
**Limitations:**
- No comments support
- Stream URLs expire after a few hours
- Trending/popular/suggestions require backing Invidious instance
- scheme name to build is Yattee. use generic platform build instead of specific sim/device id
## UI Testing with AXe
The project uses a Ruby/RSpec-based UI testing framework with [AXe](https://github.com/cameroncooke/AXe) for simulator automation and visual regression testing.
### Running UI Tests
```bash
# Install dependencies (first time)
bundle install
# Run all UI tests
./bin/ui-test
# Skip build (faster iteration)
./bin/ui-test --skip-build
# Keep simulator running after tests
./bin/ui-test --keep-simulator
# Generate new baseline screenshots
./bin/ui-test --generate-baseline
# Run on a different device
./bin/ui-test --device "iPad Pro 13-inch (M5)"
```
### Creating Tests for New Features
When implementing a new feature, create a UI test to verify it works:
1. **Create a new spec file** in `spec/ui/smoke/`:
```ruby
# spec/ui/smoke/my_feature_spec.rb
require_relative '../spec_helper'
RSpec.describe 'My New Feature', :smoke do
before(:all) do
@udid = UITest::Simulator.boot(UITest::Config.device)
UITest::App.build(device: UITest::Config.device, skip: UITest::Config.skip_build?)
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
sleep UITest::Config.app_launch_wait
@axe = UITest::Axe.new(@udid)
end
after(:all) do
UITest::App.terminate(udid: @udid, silent: true) if @udid
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
it 'displays the new feature element' do
# Navigate to the feature if needed
@axe.tap_label('Settings')
sleep 1
# Check for expected elements
expect(@axe).to have_text('My New Feature')
end
it 'matches baseline screenshot', :visual do
screenshot = @axe.screenshot('my-feature-screen')
expect(screenshot).to match_baseline
end
end
```
2. **Available AXe actions:**
```ruby
@axe.tap_label('Button Text') # Tap by accessibility label
@axe.tap_id('accessibilityId') # Tap by accessibility identifier
@axe.tap_coordinates(x: 100, y: 200)
@axe.swipe(start_x: 200, start_y: 400, end_x: 200, end_y: 100)
@axe.gesture('scroll-down') # Presets: scroll-up, scroll-down, scroll-left, scroll-right
@axe.type('search text') # Type text
@axe.home_button # Press home
@axe.screenshot('name') # Take screenshot
```
3. **Available matchers:**
```ruby
expect(@axe).to have_element('AXUniqueId') # Check by accessibility identifier
expect(@axe).to have_text('Visible Text') # Check by accessibility label
expect(screenshot_path).to match_baseline # Visual comparison (2% threshold)
```
4. **Run with baseline generation:**
```bash
./bin/ui-test --generate-baseline --keep-simulator
```
5. **Inspect accessibility tree** to find element identifiers:
```bash
# Boot simulator and launch app first, then:
axe describe-ui --udid <SIMULATOR_UDID>
```
### Directory Structure
```
spec/
├── ui/
│ ├── spec_helper.rb # RSpec configuration
│ ├── support/
│ │ ├── config.rb # Test configuration
│ │ ├── simulator.rb # Simulator management
│ │ ├── app.rb # App build/install/launch
│ │ ├── axe.rb # AXe CLI wrapper
│ │ ├── axe_matchers.rb # Custom RSpec matchers
│ │ └── screenshot_comparison.rb
│ └── smoke/
│ └── app_launch_spec.rb # Example test
└── ui_snapshots/
├── baseline/ # Reference screenshots (by device/iOS version)
│ └── iPhone_17_Pro/
│ └── iOS_26_2/
│ └── app-launch-library.png
├── current/ # Current test run screenshots
├── diff/ # Visual diff images
└── false_positives.yml # Mark expected differences
```
### Tips
- Use `have_text` matcher for most checks - it's more reliable than `have_element` since iOS doesn't always expose accessibility identifiers
- Add `sleep 1` after navigation actions to let UI settle
- Use `--keep-simulator` during development to speed up iteration
- Check `spec/ui_snapshots/diff/` for visual diff images when tests fail
- Add entries to `false_positives.yml` for screenshots with expected dynamic content

1
CNAME Normal file
View File

@@ -0,0 +1 @@
dl.yattee.stream

View File

@@ -570,7 +570,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -656,7 +656,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -800,7 +800,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -881,7 +881,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -910,7 +910,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -942,7 +942,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -977,7 +977,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1008,7 +1008,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1040,7 +1040,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1074,7 +1074,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1109,7 +1109,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -1138,7 +1138,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 262;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;

View File

@@ -192,18 +192,23 @@ final class LoggingService: Sendable {
/// OSLog output is synchronous; in-app storage is dispatched to MainActor.
nonisolated func log(level: LogLevel, category: LogCategory, message: String, details: String? = nil) {
// Log to OSLog in DEBUG builds for Xcode console visibility
// OSLog/Logger is thread-safe, so this can be called from any thread
// OSLog/Logger is thread-safe, so this can be called from any thread.
// The master "Enable Logging" toggle gates this too - it takes
// precedence over everything, so logging off means a silent console.
// (Read UserDefaults directly: this runs off the MainActor.)
#if DEBUG
let fullMessage = details.map { "\(message) - \($0)" } ?? message
switch level {
case .debug:
osLogger.debug("[\(category.rawValue)] \(fullMessage)")
case .info:
osLogger.info("[\(category.rawValue)] \(fullMessage)")
case .warning:
osLogger.warning("[\(category.rawValue)] \(fullMessage)")
case .error:
osLogger.error("[\(category.rawValue)] \(fullMessage)")
if UserDefaults.standard.bool(forKey: "loggingEnabled") {
let fullMessage = details.map { "\(message) - \($0)" } ?? message
switch level {
case .debug:
osLogger.debug("[\(category.rawValue)] \(fullMessage)")
case .info:
osLogger.info("[\(category.rawValue)] \(fullMessage)")
case .warning:
osLogger.warning("[\(category.rawValue)] \(fullMessage)")
case .error:
osLogger.error("[\(category.rawValue)] \(fullMessage)")
}
}
#endif

View File

@@ -140,6 +140,18 @@ final class MPVClient: @unchecked Sendable {
private var mpv: OpaquePointer?
private var renderContext: OpaquePointer?
private let mpvQueue = DispatchQueue(label: "stream.yattee.mpv.client", qos: .userInteractive)
/// Protects `renderContext` lifecycle vs use. Render-context calls are
/// thread-safe relative to the client API per libmpv docs, so they must NOT
/// serialize through `mpvQueue`: any slow queue work (e.g. a batched debug
/// property fetch) would block `render()` and visibly stall playback
/// (~190ms hitch every 10s on tvOS, build 264 regression).
private let renderContextLock = NSLock()
/// Serial queue for render-update callback dispatch. Deliberately separate
/// from `mpvQueue` so frame-ready notifications are never delayed behind
/// property/command work.
private let renderEventQueue = DispatchQueue(label: "stream.yattee.mpv.render-events", qos: .userInteractive)
private var isDestroyed = false
#if os(macOS)
@@ -1780,7 +1792,9 @@ final class MPVClient: @unchecked Sendable {
return false
}
renderContextLock.lock()
renderContext = ctx
renderContextLock.unlock()
log("Render context created successfully")
MPVLogging.log("createRenderContext: success")
@@ -1800,10 +1814,16 @@ final class MPVClient: @unchecked Sendable {
#else
// On iOS/tvOS, check if this update includes an actual video frame.
// This is needed for PiP frame capture which relies on onVideoFrameReady.
client.mpvQueue.async {
guard let renderCtx = client.renderContext else { return }
let flags = mpv_render_context_update(renderCtx)
let hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
// Dispatched on renderEventQueue (NOT mpvQueue) so frame notifications
// are never delayed behind slow property/command work.
client.renderEventQueue.async {
client.renderContextLock.lock()
var hasVideoFrame = false
if let renderCtx = client.renderContext, !client.isDestroyed {
let flags = mpv_render_context_update(renderCtx)
hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
}
client.renderContextLock.unlock()
// Always notify for general redraw
client.onRenderUpdate?()
@@ -1829,34 +1849,35 @@ final class MPVClient: @unchecked Sendable {
/// - Note: This may trigger a priority inversion warning because MPV's internal
/// threads run at default QoS. This is unavoidable when using mpv_render_context_render.
func render(fbo: Int32, width: Int32, height: Int32) {
mpvQueue.sync {
guard let renderContext, !isDestroyed else {
// Log when render is skipped to help diagnose black screen issues
MPVLogging.warn("render: skipped",
details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)")
return
}
renderContextLock.lock()
defer { renderContextLock.unlock() }
// GL_RGBA8 = 0x8058
var fboData = mpv_opengl_fbo(
fbo: fbo,
w: width,
h: height,
internal_format: 0x8058
)
guard let renderContext, !isDestroyed else {
// Log when render is skipped to help diagnose black screen issues
MPVLogging.warn("render: skipped",
details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)")
return
}
var flipY: Int32 = 1
// GL_RGBA8 = 0x8058
var fboData = mpv_opengl_fbo(
fbo: fbo,
w: width,
h: height,
internal_format: 0x8058
)
withUnsafeMutablePointer(to: &fboData) { fboPtr in
withUnsafeMutablePointer(to: &flipY) { flipPtr in
var params: [mpv_render_param] = [
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: fboPtr),
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flipPtr),
mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
]
_ = params.withUnsafeMutableBufferPointer { paramsPtr in
mpv_render_context_render(renderContext, paramsPtr.baseAddress)
}
var flipY: Int32 = 1
withUnsafeMutablePointer(to: &fboData) { fboPtr in
withUnsafeMutablePointer(to: &flipY) { flipPtr in
var params: [mpv_render_param] = [
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: fboPtr),
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flipPtr),
mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
]
_ = params.withUnsafeMutableBufferPointer { paramsPtr in
mpv_render_context_render(renderContext, paramsPtr.baseAddress)
}
}
}
@@ -1897,7 +1918,9 @@ final class MPVClient: @unchecked Sendable {
return false
}
renderContextLock.lock()
renderContext = ctx
renderContextLock.unlock()
log("Software render context created successfully")
MPVLogging.log("createSoftwareRenderContext: success")
@@ -1910,10 +1933,14 @@ final class MPVClient: @unchecked Sendable {
let client = Unmanaged<MPVClient>.fromOpaque(clientPtr).takeUnretainedValue()
// Check if this update includes an actual video frame
client.mpvQueue.async {
guard let renderCtx = client.renderContext else { return }
let flags = mpv_render_context_update(renderCtx)
let hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
client.renderEventQueue.async {
client.renderContextLock.lock()
var hasVideoFrame = false
if let renderCtx = client.renderContext, !client.isDestroyed {
let flags = mpv_render_context_update(renderCtx)
hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
}
client.renderContextLock.unlock()
// Always notify for general redraw
client.onRenderUpdate?()
@@ -1939,7 +1966,9 @@ final class MPVClient: @unchecked Sendable {
/// - Returns: true if a frame was rendered, false otherwise
@discardableResult
func renderSoftware(buffer: UnsafeMutableRawPointer, width: Int32, height: Int32, stride: Int) -> Bool {
mpvQueue.sync {
renderContextLock.lock()
defer { renderContextLock.unlock() }
return {
guard let renderContext, !isDestroyed else {
return false
}
@@ -1992,15 +2021,21 @@ final class MPVClient: @unchecked Sendable {
}
return true
}
}()
}
/// Report that the next frame should be rendered.
func reportRenderUpdate() {
mpvQueue.async { [weak self] in
guard let self, let renderContext = self.renderContext, !self.isDestroyed else { return }
let flags = mpv_render_context_update(renderContext)
if flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0 {
renderEventQueue.async { [weak self] in
guard let self else { return }
self.renderContextLock.lock()
var hasFrame = false
if let renderContext = self.renderContext, !self.isDestroyed {
let flags = mpv_render_context_update(renderContext)
hasFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
}
self.renderContextLock.unlock()
if hasFrame {
self.onRenderUpdate?()
}
}
@@ -2008,7 +2043,11 @@ final class MPVClient: @unchecked Sendable {
/// Destroy the render context.
func destroyRenderContext() {
// mpvQueue serializes against create; renderContextLock excludes
// in-flight render/update calls while the context is freed.
mpvQueue.sync {
renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let ctx = renderContext else {
MPVLogging.log("destroyRenderContext: no context to destroy")
return
@@ -2022,7 +2061,9 @@ final class MPVClient: @unchecked Sendable {
/// Whether the render context is initialized.
var hasRenderContext: Bool {
mpvQueue.sync { renderContext != nil }
renderContextLock.lock()
defer { renderContextLock.unlock() }
return renderContext != nil
}
// MARK: - macOS OpenGL Context Management
@@ -2052,18 +2093,21 @@ final class MPVClient: @unchecked Sendable {
// MARK: - Frame Timing
/// Report that a frame was swapped/presented (for vsync timing).
/// Called synchronously from the render thread right after present - direct
/// call (no queue hop) keeps the swap timestamp accurate for mpv's vsync
/// estimation and avoids any queue-contention delay.
func reportSwap() {
mpvQueue.async { [weak self] in
guard let ctx = self?.renderContext else { return }
mpv_render_context_report_swap(ctx)
}
renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let ctx = renderContext, !isDestroyed else { return }
mpv_render_context_report_swap(ctx)
}
/// Check if MPV has a frame ready to render (non-blocking).
/// This is safe to call from any thread - mpv's render context API is thread-safe.
func shouldRenderUpdateFrame() -> Bool {
// Don't use mpvQueue.sync here - it can cause deadlocks when called from render queue
// mpv_render_context_update is documented as thread-safe
renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let ctx = renderContext, !isDestroyed else { return false }
let flags = mpv_render_context_update(ctx)
return flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
@@ -2071,7 +2115,9 @@ final class MPVClient: @unchecked Sendable {
/// Get the render context directly (for thread-safe mpv render operations).
var mpvRenderContext: OpaquePointer? {
renderContext
renderContextLock.lock()
defer { renderContextLock.unlock() }
return renderContext
}
// MARK: - Rendering with Depth
@@ -2083,36 +2129,37 @@ final class MPVClient: @unchecked Sendable {
/// - height: Render height in pixels
/// - depth: Color depth (8 or 16 for 10-bit)
func renderWithDepth(fbo: Int32, width: Int32, height: Int32, depth: Int32) {
mpvQueue.sync {
guard let renderContext, !isDestroyed else {
MPVLogging.warn("renderWithDepth: skipped",
details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)")
return
}
renderContextLock.lock()
defer { renderContextLock.unlock() }
// GL_RGBA8 = 0x8058
var fboData = mpv_opengl_fbo(
fbo: fbo,
w: width,
h: height,
internal_format: 0x8058
)
guard let renderContext, !isDestroyed else {
MPVLogging.warn("renderWithDepth: skipped",
details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)")
return
}
var flipY: Int32 = 1
var bufferDepth = depth
// GL_RGBA8 = 0x8058
var fboData = mpv_opengl_fbo(
fbo: fbo,
w: width,
h: height,
internal_format: 0x8058
)
withUnsafeMutablePointer(to: &fboData) { fboPtr in
withUnsafeMutablePointer(to: &flipY) { flipPtr in
withUnsafeMutablePointer(to: &bufferDepth) { depthPtr in
var params: [mpv_render_param] = [
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: fboPtr),
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flipPtr),
mpv_render_param(type: MPV_RENDER_PARAM_DEPTH, data: depthPtr),
mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
]
_ = params.withUnsafeMutableBufferPointer { paramsPtr in
mpv_render_context_render(renderContext, paramsPtr.baseAddress)
}
var flipY: Int32 = 1
var bufferDepth = depth
withUnsafeMutablePointer(to: &fboData) { fboPtr in
withUnsafeMutablePointer(to: &flipY) { flipPtr in
withUnsafeMutablePointer(to: &bufferDepth) { depthPtr in
var params: [mpv_render_param] = [
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: fboPtr),
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flipPtr),
mpv_render_param(type: MPV_RENDER_PARAM_DEPTH, data: depthPtr),
mpv_render_param(type: MPV_RENDER_PARAM_INVALID, data: nil)
]
_ = params.withUnsafeMutableBufferPointer { paramsPtr in
mpv_render_context_render(renderContext, paramsPtr.baseAddress)
}
}
}

View File

@@ -37,8 +37,11 @@ enum MPVLogging {
if now - _lastCheckTime > cacheDurationNanos {
_lastCheckTime = now
// Read from UserDefaults directly for thread safety
// (SettingsManager is @MainActor)
_cachedIsEnabled = UserDefaults.standard.bool(forKey: "verboseMPVLogging")
// (SettingsManager is @MainActor).
// The master "Enable Logging" switch takes precedence: with it off,
// verbose MPV logging is off no matter what the verbose flag says.
_cachedIsEnabled = UserDefaults.standard.bool(forKey: "loggingEnabled")
&& UserDefaults.standard.bool(forKey: "verboseMPVLogging")
}
return _cachedIsEnabled

View File

@@ -836,9 +836,23 @@ final class MPVRenderView: UIView {
private var slowFrameCount: UInt64 = 0
private var lastSlowFrameWarning: Date = .distantPast
/// Debug: start time of the previous performRender, to spot gaps where mpv
/// stopped delivering frames (core-side stall vs. GL-side slow frame).
private var lastRenderStartMediaTime: CFTimeInterval = 0
private var lastGapWarning: Date = .distantPast
private func performRender() {
defer { isRendering = false }
let frameStart = Date()
let tFrameStart = CACurrentMediaTime()
let gapSincePrevFrame = lastRenderStartMediaTime > 0 ? tFrameStart - lastRenderStartMediaTime : 0
lastRenderStartMediaTime = tFrameStart
if hasRenderedFirstFrame, gapSincePrevFrame > 0.25,
Date().timeIntervalSince(lastGapWarning) > 2 {
lastGapWarning = Date()
MPVLogging.warn("performRender: frame delivery gap",
details: "gap=\(Int(gapSincePrevFrame * 1000))ms since previous frame")
}
guard let eaglContext, let mpvClient, framebuffer != 0 else {
// Log when render is skipped due to missing resources (rare but important)
@@ -894,6 +908,8 @@ final class MPVRenderView: UIView {
mpvClient.render(fbo: GLint(framebuffer), width: renderWidth, height: renderHeight)
}
let tRenderEnd = CACurrentMediaTime()
// Present the renderbuffer (skip when PiP is active - main view is hidden anyway)
if !isPiPActive {
glBindRenderbuffer(GLenum(GL_RENDERBUFFER), colorRenderbuffer)
@@ -922,8 +938,10 @@ final class MPVRenderView: UIView {
slowFrameCount += 1
if Date().timeIntervalSince(lastSlowFrameWarning) > 5 {
lastSlowFrameWarning = Date()
let renderMs = Int((tRenderEnd - tFrameStart) * 1000)
let presentMs = Int((CACurrentMediaTime() - tRenderEnd) * 1000)
MPVLogging.warn("performRender: slow frame",
details: "duration=\(Int(frameDuration * 1000))ms slowFramesSinceLastWarning=\(slowFrameCount) size=\(renderWidth)x\(renderHeight)")
details: "duration=\(Int(frameDuration * 1000))ms render=\(renderMs)ms present+swap=\(presentMs)ms gapBefore=\(Int(gapSincePrevFrame * 1000))ms slowFramesSinceLastWarning=\(slowFrameCount) size=\(renderWidth)x\(renderHeight)")
slowFrameCount = 0
}
}

View File

@@ -2063,6 +2063,15 @@ extension MPVBackend: MPVClientDelegate {
try? await Task.sleep(for: .seconds(10))
guard let self, !Task.isCancelled, let client = self.mpvClient else { return }
#if !os(macOS)
// Only macOS needs the property fetch every tick (render
// watchdog below). Elsewhere the batched mpv_get_property
// calls contend with the playback core and caused a visible
// stutter every 10s on tvOS, so skip the fetch entirely
// unless verbose logging wants the stats line.
guard MPVLogging.verboseEnabled else { continue }
#endif
let props = await client.getDebugPropertiesAsync()
#if os(macOS)

View File

@@ -92,8 +92,10 @@ final class LocalNetworkService {
}
/// Log debug-level message. Only logs if verbose remote control logging is enabled.
/// The master "Enable Logging" toggle takes precedence over the verbose flag.
private func rcDebug(_ operation: String, _ message: String) {
guard UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return }
guard UserDefaults.standard.bool(forKey: "loggingEnabled"),
UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return }
let fullMessage = "[RemoteControl] \(operation) - \(message)"
LoggingService.shared.logRemoteControlDebug(fullMessage)
}

View File

@@ -116,8 +116,10 @@ final class RemoteControlCoordinator {
}
/// Log debug-level message. Only logs if verbose remote control logging is enabled.
/// The master "Enable Logging" toggle takes precedence over the verbose flag.
private func rcDebug(_ operation: String, _ message: String) {
guard UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return }
guard UserDefaults.standard.bool(forKey: "loggingEnabled"),
UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return }
let fullMessage = "[RemoteControl] \(operation) - \(message)"
LoggingService.shared.logRemoteControlDebug(fullMessage)
}

View File

@@ -412,7 +412,12 @@ struct AdvancedSettingsView: View {
if appEnvironment?.legacyMigrationService.hasLegacyDataToImport() == true {
#if os(tvOS)
NavigationLink {
LegacyDataImportView()
TVSidebarDetailContainer(
systemImage: "person.badge.key",
title: String(localized: "migration.accounts.title")
) {
LegacyDataImportView()
}
} label: {
Label(String(localized: "migration.accounts.title"), systemImage: "person.badge.key")
}

View File

@@ -42,7 +42,11 @@ struct LegacyAccountsImportView: View {
contentList
}
}
#if !os(tvOS)
// On tvOS the title comes from the TVSidebarDetailContainer sidebar;
// a navigationTitle would render as a giant ghost behind the form.
.navigationTitle(String(localized: "migration.accounts.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@@ -63,8 +67,12 @@ struct LegacyAccountsImportView: View {
}
.confirmationDialog(
pendingRemoval?.confirmationTitle ?? "",
item: $pendingRemoval,
titleVisibility: .visible
isPresented: Binding(
get: { pendingRemoval != nil },
set: { if !$0 { pendingRemoval = nil } }
),
titleVisibility: .visible,
presenting: pendingRemoval
) { removal in
Button(String(localized: "migration.accounts.remove.confirm"), role: .destructive) {
confirmRemoval(removal)
@@ -120,6 +128,9 @@ struct LegacyAccountsImportView: View {
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
#if os(macOS)
.formStyle(.grouped)
#endif
}
private var emptyState: some View {
@@ -251,31 +262,70 @@ private struct LegacyAccountImportRow: View {
!state.username.isEmpty && !state.password.isEmpty && !state.isImporting
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2)
private var infoHeader: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: legacyRowIconWidth)
VStack(alignment: .leading, spacing: 3) {
Text(item.displayName)
.font(.headline)
Text(item.instanceDisplayName)
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(item.displayName)
.font(.headline)
Text(item.instanceDisplayName)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(item.url.host ?? item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Text(item.url.host ?? item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
}
var body: some View {
#if os(tvOS)
// On tvOS a Form row is a single focusable unit, so packing the whole card
// into one row makes the first button (Remove) swallow every click.
// Emit each interactive element as its own row instead.
infoHeader
.padding(.vertical, 8)
credentialsFields
if let errorMessage = state.errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.red)
}
Button(action: onImport) {
if state.isImporting {
HStack(spacing: 6) {
ProgressView()
Text(String(localized: "migration.importing"))
}
} else {
Text(String(localized: "migration.import"))
}
}
.buttonStyle(TVSettingsButtonStyle())
.disabled(!canImport)
Button(role: .destructive, action: onRemove) {
Text(String(localized: "common.remove"))
.foregroundStyle(.red)
}
.buttonStyle(TVSettingsButtonStyle())
.disabled(state.isImporting)
#else
VStack(alignment: .leading, spacing: 12) {
infoHeader
credentialsFields
if let errorMessage = state.errorMessage {
@@ -310,6 +360,7 @@ private struct LegacyAccountImportRow: View {
}
}
.padding(.vertical, 8)
#endif
}
@ViewBuilder
@@ -347,27 +398,50 @@ private struct LegacyInstanceImportRow: View {
let onImport: () -> Void
let onRemove: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 28)
private var infoHeader: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: legacyRowIconWidth)
VStack(alignment: .leading, spacing: 3) {
Text(item.instanceDisplayName)
.font(.headline)
VStack(alignment: .leading, spacing: 3) {
Text(item.instanceDisplayName)
.font(.headline)
Text(item.url.host ?? item.url.absoluteString)
// instanceDisplayName falls back to the host for unnamed
// instances; skip the caption instead of repeating it.
if let host = item.url.host, host != item.instanceDisplayName {
Text(host)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
Spacer()
}
}
var body: some View {
#if os(tvOS)
infoHeader
.padding(.vertical, 8)
Button(action: onImport) {
Text(String(localized: "migration.import"))
}
.buttonStyle(TVSettingsButtonStyle())
Button(role: .destructive, action: onRemove) {
Text(String(localized: "common.remove"))
.foregroundStyle(.red)
}
.buttonStyle(TVSettingsButtonStyle())
#else
VStack(alignment: .leading, spacing: 12) {
infoHeader
HStack {
Button(role: .destructive, action: onRemove) {
Text(String(localized: "common.remove"))
@@ -384,9 +458,19 @@ private struct LegacyInstanceImportRow: View {
}
}
.padding(.vertical, 8)
#endif
}
}
/// tvOS renders .title2 glyphs far larger than the 28pt frame used on other platforms.
private var legacyRowIconWidth: CGFloat {
#if os(tvOS)
return 64
#else
return 28
#endif
}
private func legacyInstanceIcon(for type: InstanceType) -> String {
switch type {
case .invidious:

View File

@@ -88,9 +88,11 @@ struct TVSettingsTextField: View {
/// Button style for settings forms - subtle focus effect without glow
struct TVSettingsButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(isEnabled ? 1 : 0.4)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(

View File

@@ -184,6 +184,22 @@ struct YatteeApp: App {
.appEnvironment(appEnvironment)
}
#endif
#if os(tvOS)
// tvOS sheets render as a small centered card that cannot fit
// the sidebar-detail layout use a full screen cover instead.
.fullScreenCover(isPresented: $showingLegacyAccountsImport) {
NavigationStack {
TVSidebarDetailContainer(
systemImage: "person.badge.key",
title: String(localized: "migration.accounts.title")
) {
LegacyAccountsImportView()
}
.appEnvironment(appEnvironment)
}
.background(Color.black.ignoresSafeArea())
}
#else
.sheet(isPresented: $showingLegacyAccountsImport) {
NavigationStack {
LegacyAccountsImportView()
@@ -193,6 +209,7 @@ struct YatteeApp: App {
.frame(minWidth: 560, minHeight: 560)
#endif
}
#endif
#if os(iOS)
.sheet(isPresented: $showingSettings) {
SettingsView()

View File

@@ -18,6 +18,15 @@
"category": "entertainment",
"screenshots": [],
"versions": [
{
"version": "2.0.0",
"buildVersion": "264",
"date": "2026-07-18T16:39:59+00:00",
"localizedDescription": "",
"downloadURL": "https://github.com/yattee/yattee/releases/download/2.0.0-beta.264/Yattee-2.0.0-iOS.ipa",
"size": 34324249,
"minOSVersion": "18.0"
},
{
"version": "2.0.0",
"buildVersion": "250",

View File

@@ -328,6 +328,8 @@ platform :mac do
build_mac_app(
scheme: SCHEME,
sdk: "macosx",
destination: "generic/platform=macOS",
configuration: "Release-DeveloperID",
output_directory: "fastlane/builds/#{version}-#{build}/macOS",
output_name: APP_NAME,
@@ -348,10 +350,12 @@ platform :mac do
# Staple the notarization ticket so Gatekeeper doesn't need to phone home
# when the user first launches from Sparkle/DMG.
sh "xcrun stapler staple 'fastlane/builds/#{version}-#{build}/macOS/#{APP_NAME}.app'"
# NOTE: sh runs from the fastlane/ directory, so paths here are relative
# to it (no fastlane/ prefix), unlike action arguments above.
sh "xcrun stapler staple 'builds/#{version}-#{build}/macOS/#{APP_NAME}.app'"
# Package the stapled .app into a .zip (Sparkle) and .dmg (Homebrew / direct).
dir = "fastlane/builds/#{version}-#{build}/macOS"
dir = "builds/#{version}-#{build}/macOS"
app = "#{dir}/#{APP_NAME}.app"
zip = "#{dir}/#{APP_NAME}-#{version}-macOS.zip"
dmg = "#{dir}/#{APP_NAME}-#{version}-macOS.dmg"