mirror of
https://github.com/yattee/yattee.git
synced 2026-08-08 16:21:28 +00:00
Compare commits
17 Commits
rewrite/v2
...
2.0.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c95f7dcbb9 | ||
|
|
c38f82062f | ||
|
|
6e36499f58 | ||
|
|
f275499c7b | ||
|
|
283c2cb9cb | ||
|
|
9c97036684 | ||
|
|
6aa5fd8ee7 | ||
|
|
c3bb01e862 | ||
|
|
94e3c25b37 | ||
|
|
658b1e827e | ||
|
|
02707ddd6a | ||
|
|
447f593950 | ||
|
|
90a0c6fd1a | ||
|
|
f3f45fdfdc | ||
|
|
a673968f1e | ||
|
|
0a91182089 | ||
|
|
c0815353d7 |
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
3
.github/workflows/update-altstore.yml
vendored
3
.github/workflows/update-altstore.yml
vendored
@@ -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 }}"
|
||||
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -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
162
CLAUDE.md
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -192,8 +192,12 @@ 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
|
||||
if UserDefaults.standard.bool(forKey: "loggingEnabled") {
|
||||
let fullMessage = details.map { "\(message) - \($0)" } ?? message
|
||||
switch level {
|
||||
case .debug:
|
||||
@@ -205,6 +209,7 @@ final class LoggingService: Sendable {
|
||||
case .error:
|
||||
osLogger.error("[\(category.rawValue)] \(fullMessage)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Dispatch in-app storage to MainActor asynchronously
|
||||
|
||||
@@ -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 }
|
||||
// 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)
|
||||
let hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
|
||||
hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
|
||||
}
|
||||
client.renderContextLock.unlock()
|
||||
|
||||
// Always notify for general redraw
|
||||
client.onRenderUpdate?()
|
||||
@@ -1829,7 +1849,9 @@ 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 {
|
||||
renderContextLock.lock()
|
||||
defer { renderContextLock.unlock() }
|
||||
|
||||
guard let renderContext, !isDestroyed else {
|
||||
// Log when render is skipped to help diagnose black screen issues
|
||||
MPVLogging.warn("render: skipped",
|
||||
@@ -1860,7 +1882,6 @@ final class MPVClient: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a render context for software rendering (CPU-based, for simulator).
|
||||
/// - Returns: Whether creation succeeded
|
||||
@@ -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 }
|
||||
client.renderEventQueue.async {
|
||||
client.renderContextLock.lock()
|
||||
var hasVideoFrame = false
|
||||
if let renderCtx = client.renderContext, !client.isDestroyed {
|
||||
let flags = mpv_render_context_update(renderCtx)
|
||||
let hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
|
||||
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 }
|
||||
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)
|
||||
if flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0 {
|
||||
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 }
|
||||
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,7 +2129,9 @@ 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 {
|
||||
renderContextLock.lock()
|
||||
defer { renderContextLock.unlock() }
|
||||
|
||||
guard let renderContext, !isDestroyed else {
|
||||
MPVLogging.warn("renderWithDepth: skipped",
|
||||
details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)")
|
||||
@@ -2117,5 +2165,4 @@ final class MPVClient: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -412,7 +412,12 @@ struct AdvancedSettingsView: View {
|
||||
if appEnvironment?.legacyMigrationService.hasLegacyDataToImport() == true {
|
||||
#if os(tvOS)
|
||||
NavigationLink {
|
||||
TVSidebarDetailContainer(
|
||||
systemImage: "person.badge.key",
|
||||
title: String(localized: "migration.accounts.title")
|
||||
) {
|
||||
LegacyDataImportView()
|
||||
}
|
||||
} label: {
|
||||
Label(String(localized: "migration.accounts.title"), systemImage: "person.badge.key")
|
||||
}
|
||||
|
||||
@@ -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,13 +262,12 @@ private struct LegacyAccountImportRow: View {
|
||||
!state.username.isEmpty && !state.password.isEmpty && !state.isImporting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
private var infoHeader: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: legacyInstanceIcon(for: item.instanceType))
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 28)
|
||||
.frame(width: legacyRowIconWidth)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(item.displayName)
|
||||
@@ -275,6 +285,46 @@ private struct LegacyAccountImportRow: View {
|
||||
|
||||
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
|
||||
|
||||
@@ -310,6 +360,7 @@ private struct LegacyAccountImportRow: View {
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -347,26 +398,49 @@ private struct LegacyInstanceImportRow: View {
|
||||
let onImport: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
private var infoHeader: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: legacyInstanceIcon(for: item.instanceType))
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 28)
|
||||
.frame(width: legacyRowIconWidth)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user