Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,58 @@ jobs:
- name: Pytest
run: python3 -I -B -m pytest tests/ -q

source-cli-python39:
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.9'

- name: Smoke-test the supported source-checkout runtime
run: |
python3 -I -B -m compileall -q scripts/
bin/modore --help >/dev/null
empty_home="$RUNNER_TEMP/modore-python39-home"
mkdir -p "$empty_home"
python3 -I -B scripts/scree.py sessions \
--home "$empty_home" --limit 1 > "$RUNNER_TEMP/sessions.json"
python3 -I -B scripts/mcp_server.py --tools > "$RUNNER_TEMP/tools.json"
python3 -I -B - <<'PY'
import json
import os
from pathlib import Path

temp = Path(os.environ["RUNNER_TEMP"])
sessions = json.loads((temp / "sessions.json").read_text(encoding="utf-8"))
tools = json.loads((temp / "tools.json").read_text(encoding="utf-8"))
assert sessions["total"] == 0
assert sessions["sessions"] == []
assert isinstance(tools["exposed"], list) and tools["exposed"]
PY

macos-python:
runs-on: macos-latest
timeout-minutes: 20

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
cache: pip
cache-dependency-path: requirements-dev.txt

- name: Install pytest (pinned via requirements-dev.txt)
run: python3 -m pip install -r requirements-dev.txt

- name: Run the full suite with Darwin-only contracts enabled
run: python3 -I -B -m pytest tests/ -q

powershell:
runs-on: windows-latest

Expand Down
2 changes: 1 addition & 1 deletion ios/Modore/Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ struct ContentView: View {
.accessibilityLabel("Refresh")
}
}
.task { model.refresh() }
.task { await model.refreshForViewLifetime() }
}
}

Expand Down
72 changes: 63 additions & 9 deletions ios/Modore/Sources/ModoreViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,87 @@ final class ModoreViewModel: ObservableObject {
@Published private(set) var media = MediaSummary.empty
@Published private(set) var isScanning = false

private let scanner: PhotoLibraryScanner
private let scanner: any PhotoLibraryScanning
private let storageSnapshot: () -> DeviceStorageSnapshot?
private let viewLifetimeSuspension: @Sendable () async -> Void
private var operationTask: Task<Void, Never>?
private var viewLifetimeGeneration: UInt64 = 0

init(scanner: PhotoLibraryScanner = PhotoLibraryScanner()) {
init(
scanner: any PhotoLibraryScanning = PhotoLibraryScanner(),
storageSnapshot: @escaping () -> DeviceStorageSnapshot? = {
DeviceStorageSnapshot.current()
},
viewLifetimeSuspension: @escaping @Sendable () async -> Void = {
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: 3_600_000_000_000)
} catch {
break
}
}
}
) {
self.scanner = scanner
self.storageSnapshot = storageSnapshot
self.viewLifetimeSuspension = viewLifetimeSuspension
}

deinit {
operationTask?.cancel()
}

var authorization: PhotoAuthorization { media.authorization }

func refresh() {
storage = DeviceStorageSnapshot.current()
storage = storageSnapshot()
guard !isScanning else { return }
isScanning = true
let scanner = self.scanner
Task { @MainActor in
media = await scanner.scan()
isScanning = false
operationTask = Task { @MainActor [weak self, scanner] in
let summary = await scanner.scan()
guard !Task.isCancelled else { return }
self?.finish(with: summary)
}
}

func requestPhotoAccess() {
guard !isScanning else { return }
isScanning = true
let scanner = self.scanner
Task { @MainActor in
operationTask = Task { @MainActor [weak self, scanner] in
_ = await scanner.requestAuthorization()
media = await scanner.scan()
isScanning = false
guard !Task.isCancelled else { return }
let summary = await scanner.scan()
guard !Task.isCancelled else { return }
self?.finish(with: summary)
}
}

func refreshForViewLifetime() async {
guard !Task.isCancelled else { return }
viewLifetimeGeneration &+= 1
let generation = viewLifetimeGeneration
refresh()
await viewLifetimeSuspension()
guard generation == viewLifetimeGeneration else { return }
cancelCurrentOperation()
}

func cancelCurrentOperation() {
operationTask?.cancel()
operationTask = nil
isScanning = false
}

func waitForCurrentOperation() async {
let task = operationTask
await task?.value
}

private func finish(with summary: MediaSummary) {
media = summary
isScanning = false
operationTask = nil
}
}
178 changes: 128 additions & 50 deletions ios/Modore/Sources/PhotoLibraryScanner.swift
Original file line number Diff line number Diff line change
@@ -1,63 +1,67 @@
import Foundation
import Photos

struct PhotoLibraryScanner: Sendable {
func authorizationStatus() -> PhotoAuthorization {
PhotoAuthorization(PHPhotoLibrary.authorizationStatus(for: .readWrite))
}
struct PhotoVideoAsset: Equatable, Sendable {
let id: String
let duration: TimeInterval
let createdAt: Date?
let isScreenRecording: Bool
}

func requestAuthorization() async -> PhotoAuthorization {
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
return PhotoAuthorization(status)
}
protocol PhotoLibraryAccessing: Sendable {
func authorizationStatus() async -> PhotoAuthorization
func requestAuthorization() async -> PhotoAuthorization
func summarizeVideoAssets(authorization: PhotoAuthorization) async -> MediaSummary
}

func scan() async -> MediaSummary {
await Task.detached(priority: .userInitiated) {
scanSynchronously()
}.value
enum PhotoLibraryWorker {
/// PhotoKit's synchronous fetch has no cancellation API. Keep it off the
/// caller's executor and propagate cancellation to the worker so it stops
/// before enumeration, or at the next asset if fetch has already returned.
static func run<T: Sendable>(
_ operation: @escaping @Sendable () -> T
) async -> T {
let worker = Task.detached(priority: .userInitiated, operation: operation)
return await withTaskCancellationHandler {
await worker.value
} onCancel: {
worker.cancel()
}
}
}

private func scanSynchronously() -> MediaSummary {
let authorization = authorizationStatus()
guard authorization == .authorized || authorization == .limited else {
return MediaSummary(
videoCount: 0,
videoDuration: 0,
screenRecordingCount: 0,
screenRecordingDuration: 0,
longestVideos: [],
authorization: authorization
)
}
struct PhotoVideoSummaryAccumulator: Sendable {
private(set) var videoCount = 0
private(set) var videoDuration: TimeInterval = 0
private(set) var screenRecordingCount = 0
private(set) var screenRecordingDuration: TimeInterval = 0
private var longestVideos: [MediaCandidate] = []

let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
let assets = PHAsset.fetchAssets(with: .video, options: options)
var videoCount = 0
var videoDuration: TimeInterval = 0
var screenRecordingCount = 0
var screenRecordingDuration: TimeInterval = 0
var longestVideos: [MediaCandidate] = []

assets.enumerateObjects { asset, _, _ in
let candidate = MediaCandidate(
id: asset.localIdentifier,
duration: max(0, asset.duration),
createdAt: asset.creationDate,
isScreenRecording: asset.mediaSubtypes.contains(.videoScreenRecording)
)
videoCount += 1
videoDuration += candidate.duration
if candidate.isScreenRecording {
screenRecordingCount += 1
screenRecordingDuration += candidate.duration
}
longestVideos.append(candidate)
longestVideos.sort(by: Self.longerFirst)
if longestVideos.count > 5 { longestVideos.removeLast() }
var retainedCandidateCount: Int { longestVideos.count }

@discardableResult
mutating func consume(_ asset: PhotoVideoAsset) -> Bool {
guard !Task.isCancelled else { return false }
let candidate = MediaCandidate(
id: asset.id,
duration: max(0, asset.duration),
createdAt: asset.createdAt,
isScreenRecording: asset.isScreenRecording
)
videoCount += 1
videoDuration += candidate.duration
if candidate.isScreenRecording {
screenRecordingCount += 1
screenRecordingDuration += candidate.duration
}
longestVideos.append(candidate)
longestVideos.sort(by: Self.longerFirst)
if longestVideos.count > 5 { longestVideos.removeLast() }
return true
}

return MediaSummary(
func summary(authorization: PhotoAuthorization) -> MediaSummary {
MediaSummary(
videoCount: videoCount,
videoDuration: videoDuration,
screenRecordingCount: screenRecordingCount,
Expand All @@ -72,3 +76,77 @@ struct PhotoLibraryScanner: Sendable {
return lhs.duration > rhs.duration
}
}

struct SystemPhotoLibraryAccess: PhotoLibraryAccessing {
func authorizationStatus() async -> PhotoAuthorization {
PhotoAuthorization(PHPhotoLibrary.authorizationStatus(for: .readWrite))
}

func requestAuthorization() async -> PhotoAuthorization {
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
return PhotoAuthorization(status)
}

func summarizeVideoAssets(authorization: PhotoAuthorization) async -> MediaSummary {
await PhotoLibraryWorker.run {
var accumulator = PhotoVideoSummaryAccumulator()
guard !Task.isCancelled else {
return accumulator.summary(authorization: authorization)
}
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
let assets = PHAsset.fetchAssets(with: .video, options: options)
guard !Task.isCancelled else {
return accumulator.summary(authorization: authorization)
}
assets.enumerateObjects { asset, _, stop in
let consumed = accumulator.consume(PhotoVideoAsset(
id: asset.localIdentifier,
duration: asset.duration,
createdAt: asset.creationDate,
isScreenRecording: asset.mediaSubtypes.contains(.videoScreenRecording)
))
if !consumed {
stop.pointee = true
}
}
return accumulator.summary(authorization: authorization)
}
}
}

protocol PhotoLibraryScanning: Sendable {
func requestAuthorization() async -> PhotoAuthorization
func scan() async -> MediaSummary
}

struct PhotoLibraryScanner: PhotoLibraryScanning {
private let library: any PhotoLibraryAccessing

init(library: any PhotoLibraryAccessing = SystemPhotoLibraryAccess()) {
self.library = library
}

func requestAuthorization() async -> PhotoAuthorization {
await library.requestAuthorization()
}

func scan() async -> MediaSummary {
let authorization = await library.authorizationStatus()
guard authorization == .authorized || authorization == .limited else {
return Self.emptySummary(authorization: authorization)
}
return await library.summarizeVideoAssets(authorization: authorization)
}

private static func emptySummary(authorization: PhotoAuthorization) -> MediaSummary {
MediaSummary(
videoCount: 0,
videoDuration: 0,
screenRecordingCount: 0,
screenRecordingDuration: 0,
longestVideos: [],
authorization: authorization
)
}
}
Loading