Compare commits

..

12 Commits

Author SHA1 Message Date
midzelis
206a07d8db refactor(web): improve CancellableTask naming and add state machine docs
- Rename 'LOADED' return status → 'SUCCESS' (domain-agnostic)
- Rename loaded → succeeded, loadedCallback → succeededCallback
- Rename waitUntilLoaded → waitUntilSucceeded
- Rename cancelToken → abortController (matches AbortController API)
- Rename executed → succeeded, loading → running
- Simplify cancellable downgrade logic
- Add state machine documentation comment

Change-Id: I701e0065d355fca4328d64b7ce42a6f06a6a6964
2026-04-06 18:22:24 +00:00
midzelis
0a93963041 fix(web): handle unhandled promise rejection in CancellableTask
When a concurrent caller awaits `this.complete` inside `execute()` and
`cancel()` is called, the promise rejects with `undefined` outside of any
try/catch, causing "Uncaught (in promise) undefined" console spam during
rapid timeline scrolling.

- Wrap the `await this.complete` path in try/catch, returning 'CANCELED'
- Guard the `finally` block to only null `cancelToken` if it still belongs
  to this call, preventing a race condition with `cancel()` to `init()`

Change-Id: I65764dd664eb408433fc6e5fc2be4df56a6a6964
2026-04-06 17:43:56 +00:00
Afonso Mendonça Ribeiro
95c1f0efeb fix: timestamp handling for database backup in Web UI (#27359)
* Fix #26502: Fix timestamp handling for database backup in Web UI

Frontend parsed backup timestamps as UTC, but they were in the
server's local timezone, causing wrong relative times.

Add `timezone` field to DatabaseBackupDto to expose server timezone.
Update frontend to parse timestamps using this timezone.
Convert timestamps to user's local timezone before rendering.
Fallback to browser timezone if server timezone is missing.

Ensures correct relative time display in Web UI.

* fix: regenerate open-api types and remove custom backup type

- Ran `make open-api` to update types based on backend changes
- Removed custom BackupWithTimezone type in MaintenanceBackupsList
- Updated timezone props to use the newly generated native type

* fix: simplify timezone handling for database backups

- Updated DatabaseBackupDto to make timezone a required property
- Removed manual DateTime.local().zoneName fallbacks
- Cleaned up type casts after regenerating OpenAPI types

* fix: Add missing newline at end of spec file
2026-04-06 17:27:48 +02:00
Thomas
fbe631fe91 fix(mobile): convert video controls from hook to stateful widget (#27514)
We are generally looking to move away from hooks as they are hard to
reason about and have weird bugs. In particular, the timer did not
properly capture the ref of the callback, and so it would execute on old
state. A standard stateful widget does not have this problem, and is
easier to organise.

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-04-06 10:13:45 -05:00
Putu Prema
2143a0c935 fix(mobile): transparent system navbar when trash bottom bar is visible (#27093)
* disable bottom safe area on trash bottom bar so that it extends below the system nav bar

* remove manual padding calculations

* re-add static vertical padding to maintain previous bottom bar height
2026-04-06 09:28:07 -05:00
Ray
136bd1e2eb feat(server): Add support for .ts files (#27529) 2026-04-06 15:50:05 +02:00
Min Idzelis
564065a3ed fix(web): reset cursor style when slideshow bar unmounts (#27521) 2026-04-06 14:07:49 +02:00
Min Idzelis
9bcce59719 fix(e2e): fix search gallery delete tests (#27536) 2026-04-06 14:00:50 +02:00
Luis Nachtigall
cd86a83c33 refactor(mobile): introduce image request registry on iOS (#27486)
* refactor: replace DispatchQueue + DispatchSemaphore with OperationQueue for image processing

* implement RequestRegistry and UnfairLock for managing cancellable requests

* implement requests registry for local and remote image processing

* remove Cancellable protocol and cancel method from request registry

* use mutex

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-04-05 18:55:43 -04:00
Min Idzelis
f29c06799f fix(web): isFullScreen initial value check is incorrect (#27520) 2026-04-05 21:19:58 +00:00
Luis Nachtigall
6fcf651d76 refactor(mobile): IOS replace DispatchQueue + DispatchSemaphore with OperationQueue for image processing (#27471)
refactor: replace DispatchQueue + DispatchSemaphore with OperationQueue for image processing
2026-04-05 16:11:02 -04:00
Zack Pollard
196307bca5 chore(server): use dev version check endpoint for non-production environments (#27508) 2026-04-05 10:52:59 +01:00
34 changed files with 578 additions and 368 deletions

View File

@@ -28,17 +28,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
## Video formats
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |

View File

@@ -173,6 +173,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
'.mpeg',
'.mpg',
'.mts',
'.ts',
'.vob',
'.webm',
'.wmv',

View File

@@ -6,6 +6,7 @@ import {
generateTimelineData,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => {
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
@@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => {
await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') {
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
const searchAssets = assets
.slice(0, 5)
.filter((asset) => !changes.assetDeletions.includes(asset.id))
.map((asset) => toAssetResponseDto(asset));
return route.fulfill({
status: 200,
contentType: 'application/json',

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -16,6 +16,7 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DD6982F7F43B40049AB63 /* ImageRequest.swift */; };
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; };
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
@@ -102,6 +103,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = "<group>"; };
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
@@ -137,20 +139,23 @@
);
target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */;
};
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Mutex.swift,
);
target = 97C146ED1CF9000F007C117D /* Runner */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -162,10 +167,16 @@
path = WidgetExtension;
sourceTree = "<group>";
};
FEE084F22EC172080045228E /* Schemas */ = {
FE1BB4562F8319560087DBF9 /* Utility */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */,
);
path = Utility;
sourceTree = "<group>";
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Schemas;
sourceTree = "<group>";
};
@@ -273,6 +284,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
FE1BB4562F8319560087DBF9 /* Utility */,
FEE084F22EC172080045228E /* Schemas */,
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
@@ -327,6 +339,7 @@
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */,
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */,
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
@@ -558,10 +571,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -590,10 +607,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -608,6 +629,7 @@
files = (
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,

View File

@@ -1,7 +1,12 @@
import Foundation
enum ImageProcessing {
static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent)
static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
static let queue = {
let q = OperationQueue()
q.name = "thumbnail.processing"
q.qualityOfService = .userInitiated
q.maxConcurrentOperationCount = ProcessInfo.processInfo.activeProcessorCount * 2
return q
}()
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
}

View File

@@ -0,0 +1,14 @@
import Foundation
struct RequestRegistry<T: AnyObject & Sendable>: ~Copyable, Sendable {
private let requests = Mutex<[Int64: T]>([:])
func add(requestId: Int64, request: T) {
requests.withLock { $0[requestId] = request }
}
@discardableResult
func remove(requestId: Int64) -> T? {
requests.withLock { $0.removeValue(forKey: requestId) }
}
}

View File

@@ -4,13 +4,18 @@ import MobileCoreServices
import Photos
class LocalImageRequest {
weak var workItem: DispatchWorkItem?
weak var operation: Operation?
var isCancelled = false
let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback
}
func cancel() {
isCancelled = true
operation?.cancel()
}
}
class LocalImageApiImpl: LocalImageApi {
@@ -31,9 +36,7 @@ class LocalImageApiImpl: LocalImageApi {
return requestOptions
}()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let registry = RequestRegistry<LocalImageRequest>()
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
@@ -42,7 +45,6 @@ class LocalImageApiImpl: LocalImageApi {
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .defaultIntent
)!
private static var requests = [Int64: LocalImageRequest]()
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
@@ -50,7 +52,7 @@ class LocalImageApiImpl: LocalImageApi {
}()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.async {
ImageProcessing.queue.addOperation {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
@@ -66,23 +68,14 @@ class LocalImageApiImpl: LocalImageApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
let request = LocalImageRequest(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
ImageProcessing.semaphore.wait()
defer {
ImageProcessing.semaphore.signal()
}
let operation = BlockOperation {
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
@@ -107,12 +100,11 @@ class LocalImageApiImpl: LocalImageApi {
)
if request.isCancelled {
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
guard let data = imageData else {
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
}
@@ -122,7 +114,6 @@ class LocalImageApiImpl: LocalImageApi {
if request.isCancelled {
free(pointer)
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
@@ -130,7 +121,7 @@ class LocalImageApiImpl: LocalImageApi {
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
return
}
@@ -151,7 +142,7 @@ class LocalImageApiImpl: LocalImageApi {
guard let image = image,
let cgImage = image.cgImage else {
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
@@ -171,51 +162,32 @@ class LocalImageApiImpl: LocalImageApi {
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
"rowBytes": Int64(buffer.rowBytes),
]))
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
}
}
request.workItem = item
Self.add(requestId: requestId, request: request)
ImageProcessing.queue.async(execute: item)
request.operation = operation
Self.registry.add(requestId: requestId, request: request)
ImageProcessing.queue.addOperation(operation)
}
func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId)
}
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancel(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let item = request.workItem else { return }
if item.isCancelled {
cancelQueue.async { request.callback(ImageProcessing.cancelledResult) }
}
}
Self.registry.remove(requestId: requestId)?.cancel()
}
private static func requestAsset(assetId: String) -> PHAsset? {
var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset }
if let cached = assetCache.object(forKey: assetId as NSString) {
return cached
}
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
assetCache.setObject(asset, forKey: assetId as NSString)
return asset
}
}

View File

@@ -14,11 +14,15 @@ class RemoteImageRequest {
self.task = task
self.completion = completion
}
func cancel() {
isCancelled = true
task?.cancel()
}
}
class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static var lock = os_unfair_lock()
private static var requests = [Int64: RemoteImageRequest]()
private static let registry = RequestRegistry<RemoteImageRequest>()
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
@@ -43,20 +47,15 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock)
Self.registry.add(requestId: requestId, request: request)
task.resume()
}
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
os_unfair_lock_lock(&Self.lock)
guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock)
guard let request = registry.remove(requestId: requestId) else {
return
}
requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock)
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
@@ -73,10 +72,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
ImageProcessing.queue.async {
ImageProcessing.semaphore.wait()
defer { ImageProcessing.semaphore.signal() }
ImageProcessing.queue.addOperation {
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
@@ -130,13 +126,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
}
func cancelRequest(requestId: Int64) {
os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return }
request.isCancelled = true
request.task?.cancel()
Self.registry.remove(requestId: requestId)?.cancel()
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {

View File

@@ -0,0 +1,54 @@
import Darwin
// Can be replaced with std Mutex when the deployment target is iOS 18+
struct Mutex<Value: ~Copyable>: ~Copyable, @unchecked Sendable {
struct _Buffer: ~Copyable {
var lock: os_unfair_lock = .init()
var value: Value
init(value: consuming Value) {
self.value = value
}
deinit {}
}
let _buffer: UnsafeMutablePointer<_Buffer>
init(_ initialValue: consuming sending Value) {
_buffer = .allocate(capacity: 1)
_buffer.initialize(to: _Buffer(value: initialValue))
}
deinit {
_buffer.deinitialize(count: 1)
_buffer.deallocate()
}
@discardableResult
borrowing func withLock<Result: ~Copyable, E: Error>(
_ body: (inout sending Value) throws(E) -> sending Result
) throws(E) -> sending Result {
os_unfair_lock_lock(&_buffer.pointee.lock)
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
return try body(&_buffer.pointee.value)
}
}
// Can be replaced with OSAllocatedUnfairLock when the deployment target is iOS 16+
typealias UnfairLock = Mutex<Void>
extension Mutex where Value == Void {
init() {
self.init(())
}
@discardableResult
borrowing func withLock<Result: ~Copyable, E: Error>(
_ body: () throws(E) -> sending Result
) throws(E) -> sending Result {
os_unfair_lock_lock(&_buffer.pointee.lock)
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
return try body()
}
}

View File

@@ -10,20 +10,19 @@ class TrashBottomBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: const Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
],
),
return Align(
alignment: Alignment.bottomCenter,
child: Container(
color: context.themeData.canvasColor,
padding: const EdgeInsets.symmetric(vertical: 8),
child: const SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
],
),
),
),

View File

@@ -1,17 +1,36 @@
import 'package:async/async.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
RestartableTimer useTimer(Duration duration, VoidCallback callback) {
final latest = useRef(callback);
latest.value = callback;
final timer = useMemoized(
() => RestartableTimer(duration, () => latest.value()),
[duration],
);
useEffect(() => timer.cancel, [timer]);
return timer;
RestartableTimer useTimer(Duration duration, void Function() callback) {
return use(_TimerHook(duration: duration, callback: callback));
}
class _TimerHook extends Hook<RestartableTimer> {
final Duration duration;
final void Function() callback;
const _TimerHook({required this.duration, required this.callback});
@override
HookState<RestartableTimer, Hook<RestartableTimer>> createState() => _TimerHookState();
}
class _TimerHookState extends HookState<RestartableTimer, _TimerHook> {
late RestartableTimer timer;
@override
void initHook() {
super.initHook();
timer = RestartableTimer(hook.duration, hook.callback);
}
@override
RestartableTimer build(BuildContext context) {
return timer;
}
@override
void dispose() {
timer.cancel();
super.dispose();
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:math';
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
@@ -7,26 +8,63 @@ import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
class VideoControls extends HookConsumerWidget {
class VideoControls extends ConsumerStatefulWidget {
final String videoPlayerName;
static const List<Shadow> _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))];
const VideoControls({super.key, required this.videoPlayerName});
void _toggle(WidgetRef ref, bool isCasting) {
if (isCasting) {
ref.read(castProvider.notifier).toggle();
} else {
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
@override
ConsumerState<VideoControls> createState() => _VideoControlsState();
}
class _VideoControlsState extends ConsumerState<VideoControls> {
late final RestartableTimer _hideTimer;
AutoDisposeStateNotifierProvider<VideoPlayerNotifier, VideoPlayerState> get _provider =>
videoPlayerProvider(widget.videoPlayerName);
@override
void initState() {
super.initState();
_hideTimer = RestartableTimer(const Duration(seconds: 5), _onHideTimer);
}
@override
void didUpdateWidget(covariant VideoControls oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.videoPlayerName != widget.videoPlayerName) {
_hideTimer.reset();
}
}
void _onSeek(WidgetRef ref, bool isCasting, double value) {
@override
void dispose() {
_hideTimer.cancel();
super.dispose();
}
void _onHideTimer() {
if (!mounted) return;
if (ref.read(_provider).status == VideoPlaybackStatus.playing) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
void _toggle(bool isCasting) {
if (isCasting) {
ref.read(castProvider.notifier).toggle();
return;
}
ref.read(_provider.notifier).toggle();
}
void _onSeek(bool isCasting, double value) {
final seekTo = Duration(microseconds: value.toInt());
if (isCasting) {
@@ -34,38 +72,30 @@ class VideoControls extends HookConsumerWidget {
return;
}
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
ref.read(_provider.notifier).seekTo(seekTo);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = videoPlayerProvider(videoPlayerName);
Widget build(BuildContext context) {
final cast = ref.watch(castProvider);
final isCasting = cast.isCasting;
final (position, duration) = isCasting
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
: ref.watch(provider.select((v) => (v.position, v.duration)));
: ref.watch(_provider.select((v) => (v.position, v.duration)));
final videoStatus = ref.watch(provider.select((v) => v.status));
final videoStatus = ref.watch(_provider.select((v) => v.status));
final isPlaying = isCasting
? cast.castState == CastState.playing
: videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering;
final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed;
final hideTimer = useTimer(const Duration(seconds: 5), () {
if (!context.mounted) return;
if (ref.read(provider).status == VideoPlaybackStatus.playing) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
ref.listen(assetViewerProvider.select((v) => v.showingControls), (prev, showing) {
if (showing && prev != showing) hideTimer.reset();
if (showing && prev != showing) _hideTimer.reset();
});
ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset());
ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset());
final notifier = ref.read(provider.notifier);
final notifier = ref.read(_provider.notifier);
final isLoaded = duration != Duration.zero;
return Padding(
@@ -80,9 +110,13 @@ class VideoControls extends HookConsumerWidget {
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
icon: isFinished
? const Icon(Icons.replay, color: Colors.white, shadows: _controlShadows)
: AnimatedPlayPause(color: Colors.white, playing: isPlaying, shadows: _controlShadows),
onPressed: () => _toggle(ref, isCasting),
? const Icon(Icons.replay, color: Colors.white, shadows: VideoControls._controlShadows)
: AnimatedPlayPause(
color: Colors.white,
playing: isPlaying,
shadows: VideoControls._controlShadows,
),
onPressed: () => _toggle(isCasting),
),
const Spacer(),
Text(
@@ -91,7 +125,7 @@ class VideoControls extends HookConsumerWidget {
color: Colors.white,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
shadows: _controlShadows,
shadows: VideoControls._controlShadows,
),
),
const SizedBox(width: 12),
@@ -107,7 +141,7 @@ class VideoControls extends HookConsumerWidget {
padding: EdgeInsets.zero,
onChangeStart: (_) => notifier.hold(),
onChangeEnd: (_) => notifier.release(),
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
onChanged: isLoaded ? (value) => _onSeek(isCasting, value) : null,
),
],
),

View File

@@ -15,30 +15,36 @@ class DatabaseBackupDto {
DatabaseBackupDto({
required this.filename,
required this.filesize,
required this.timezone,
});
String filename;
num filesize;
String timezone;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDto &&
other.filename == filename &&
other.filesize == filesize;
other.filesize == filesize &&
other.timezone == timezone;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filename.hashCode) +
(filesize.hashCode);
(filesize.hashCode) +
(timezone.hashCode);
@override
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize]';
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize, timezone=$timezone]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'filename'] = this.filename;
json[r'filesize'] = this.filesize;
json[r'timezone'] = this.timezone;
return json;
}
@@ -53,6 +59,7 @@ class DatabaseBackupDto {
return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!,
filesize: num.parse('${json[r'filesize']}'),
timezone: mapValueOfType<String>(json, r'timezone')!,
);
}
return null;
@@ -102,6 +109,7 @@ class DatabaseBackupDto {
static const requiredKeys = <String>{
'filename',
'filesize',
'timezone',
};
}

View File

@@ -17721,11 +17721,15 @@
},
"filesize": {
"type": "number"
},
"timezone": {
"type": "string"
}
},
"required": [
"filename",
"filesize"
"filesize",
"timezone"
],
"type": "object"
},

View File

@@ -63,6 +63,7 @@ export type DatabaseBackupDeleteDto = {
export type DatabaseBackupDto = {
filename: string;
filesize: number;
timezone: string;
};
export type DatabaseBackupListResponseDto = {
backups: DatabaseBackupDto[];

View File

@@ -4,6 +4,7 @@ import { IsString } from 'class-validator';
export class DatabaseBackupDto {
filename!: string;
filesize!: number;
timezone!: string;
}
export class DatabaseBackupListResponseDto {

View File

@@ -75,6 +75,10 @@ export interface EnvData {
server: string;
};
versionCheck: {
url: string;
};
network: {
trustedProxies: string[];
};
@@ -320,6 +324,10 @@ const getEnv = (): EnvData => {
licensePublicKey: isProd ? productionKeys : stagingKeys,
versionCheck: {
url: isProd ? 'https://version.immich.cloud/version' : 'https://version.dev.immich.cloud/version',
},
network: {
trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? ['linklocal', 'uniquelocal'],
},

View File

@@ -66,7 +66,8 @@ export class ServerInfoRepository {
async getLatestRelease(): Promise<VersionResponse> {
try {
const response = await fetch('https://version.immich.cloud/version');
const { versionCheck } = this.configRepository.getEnv();
const response = await fetch(versionCheck.url);
if (!response.ok) {
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);

View File

@@ -111,6 +111,7 @@ const validVideos = [
'.mpg',
'.mts',
'.mxf',
'.ts',
'.vob',
'.webm',
'.wmv',

View File

@@ -283,6 +283,7 @@ export class DatabaseBackupService {
async listBackups(): Promise<DatabaseBackupListResponseDto> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const files = await this.storageRepository.readdir(backupsFolder);
const timezone = DateTime.local().zoneName;
const validFiles = files
.filter((fn) => isValidDatabaseBackupName(fn))
@@ -292,7 +293,7 @@ export class DatabaseBackupService {
const backups = await Promise.all(
validFiles.map(async (filename) => {
const stats = await this.storageRepository.stat(path.join(backupsFolder, filename));
return { filename, filesize: stats.size };
return { filename, filesize: stats.size, timezone };
}),
);

View File

@@ -2,9 +2,8 @@ import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants';
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -73,15 +72,6 @@ describe(VersionService.name, () => {
});
describe('handVersionCheck', () => {
beforeEach(() => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Production }));
});
it('should not run in dev mode', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Development }));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
});
it('should not run if the last check was < 60 minutes ago', async () => {
mocks.systemMetadata.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),

View File

@@ -4,7 +4,7 @@ import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { DatabaseLock, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types';
@@ -71,11 +71,6 @@ export class VersionService extends BaseService {
try {
this.logger.debug('Running version check');
const { environment } = this.configRepository.getEnv();
if (environment === ImmichEnvironment.Development) {
return JobStatus.Skipped;
}
const { newVersionCheck } = await this.getConfig({ withCache: true });
if (!newVersionCheck.enabled) {
return JobStatus.Skipped;

View File

@@ -83,6 +83,7 @@ describe('mimeTypes', () => {
{ mimetype: 'video/mp2t', extension: '.m2t' },
{ mimetype: 'video/mp2t', extension: '.m2ts' },
{ mimetype: 'video/mp2t', extension: '.mts' },
{ mimetype: 'video/mp2t', extension: '.ts' },
{ mimetype: 'video/mp4', extension: '.mp4' },
{ mimetype: 'video/mpeg', extension: '.mpe' },
{ mimetype: 'video/mpeg', extension: '.mpeg' },

View File

@@ -114,6 +114,7 @@ const video: Record<string, string[]> = {
'.mpg': ['video/mpeg'],
'.mts': ['video/mp2t'],
'.mxf': ['application/mxf'],
'.ts': ['video/mp2t'],
'.vob': ['video/mpeg'],
'.webm': ['video/webm'],
'.wmv': ['video/x-ms-wmv'],

View File

@@ -44,6 +44,10 @@ const envData: EnvData = {
server: 'server-public-key',
},
versionCheck: {
url: 'https://version.immich.cloud/version',
},
network: {
trustedProxies: [],
},

View File

@@ -276,7 +276,6 @@
const handleStopSlideshow = async () => {
try {
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
}
} catch (error) {
@@ -338,7 +337,7 @@
onAction?.(action);
};
let isFullScreen = $derived(fullscreenElement !== null);
let isFullScreen = $derived(!!fullscreenElement);
$effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {

View File

@@ -85,6 +85,7 @@
});
onDestroy(() => {
setCursorStyle('');
if (unsubscribeRestart) {
unsubscribeRestart();
}

View File

@@ -0,0 +1,54 @@
import { locale } from '$lib/stores/preferences.store';
import { renderWithTooltips } from '$tests/helpers';
import { screen } from '@testing-library/svelte';
import { DateTime } from 'luxon';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte';
vi.mock('$lib/services/database-backups.service', () => ({
getDatabaseBackupActions: () => ({
Download: { type: 'command', title: 'Download', onAction: vi.fn() },
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() },
}),
handleRestoreDatabaseBackup: vi.fn(),
}));
describe('MaintenanceBackupEntry', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-24T12:00:00Z'));
locale.set('en');
});
afterEach(() => {
vi.useRealTimers();
});
it('renders relative backup time using the user timezone instead of UTC', () => {
const backupTimestamp = '20260324T110000';
const expectedRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
zone: 'Asia/Tokyo',
})
.toLocal()
.toRelative({ locale: 'en' });
const utcRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
zone: 'UTC',
})
.toLocal()
.toRelative({ locale: 'en' });
expect(expectedRelativeTime).toBeTruthy();
expect(expectedRelativeTime).not.toEqual(utcRelativeTime);
renderWithTooltips(MaintenanceBackupEntry, {
expectedVersion: '1.2.3',
filename: 'immich-db-backup-20260324T110000-v1.2.3-snapshot.sql.gz',
filesize: 1024,
timezone: 'Asia/Tokyo',
});
expect(screen.getByText(expectedRelativeTime!)).toBeInTheDocument();
});
});

View File

@@ -13,16 +13,17 @@
filename: string;
filesize: number;
expectedVersion: string;
timezone?: string;
};
const { filename, filesize, expectedVersion }: Props = $props();
const { filename, filesize, expectedVersion, timezone }: Props = $props();
const filesizeText = $derived(getBytesWithUnit(filesize, 1));
const backupDateTime = $derived.by(() => {
const dateMatch = filename.match(/\d+T\d+/);
if (dateMatch) {
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' }).toLocal();
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal();
}
return null;
});

View File

@@ -51,12 +51,13 @@
const unknownDateKey = $t('unknown_date');
for (const backup of backups) {
const timezone = backup.timezone;
const dateMatch = backup.filename.match(/\d+T\d+/);
let dateKey: string;
let dt: DateTime;
if (dateMatch) {
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' });
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone });
dateKey = dt.toFormat('LLLL d, yyyy');
} else {
dt = DateTime.fromMillis(0);
@@ -128,6 +129,7 @@
filename={backup.filename}
filesize={backup.filesize}
expectedVersion={props.expectedVersion}
timezone={backup.timezone}
/>
{/each}
</Stack>

View File

@@ -145,7 +145,7 @@ describe('TimelineManager', () => {
it('cancels month loading', async () => {
const month = getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })!;
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
const abortSpy = vi.spyOn(month!.loader!.abortController!, 'abort');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
@@ -638,12 +638,8 @@ describe('TimelineManager', () => {
const previousMonth = getTimelineMonthByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getFirstAsset();
const b = previousMonth!.getFirstAsset();
const loadTimelineMonthSpy = vi.spyOn(month!.loader!, 'execute');
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {

View File

@@ -307,8 +307,8 @@ export class TimelineManager extends VirtualScrollManager {
return;
}
if (!this.initTask.executed) {
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
if (!this.initTask.succeeded) {
await (this.initTask.running ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
}
const changedWidth = viewport.width !== this.viewportWidth;
@@ -351,14 +351,10 @@ export class TimelineManager extends VirtualScrollManager {
return;
}
if (timelineMonth.loader?.executed) {
return;
}
const executionStatus = await timelineMonth.loader?.execute(async (signal: AbortSignal) => {
await loadFromTimeBuckets(this, timelineMonth, this.#options, signal);
}, cancelable);
if (executionStatus === 'LOADED') {
if (executionStatus === 'SUCCESS') {
updateGeometry(this, timelineMonth, { invalidateHeight: false });
this.updateViewportProximities();
}
@@ -372,7 +368,7 @@ export class TimelineManager extends VirtualScrollManager {
async findTimelineMonthForAsset(asset: AssetDescriptor | AssetResponseDto) {
if (!this.isInitialized) {
await this.initTask.waitUntilExecution();
await this.initTask.waitUntilSucceeded();
}
const { id } = asset;

View File

@@ -2,39 +2,39 @@ import { CancellableTask } from '$lib/utils/cancellable-task';
describe('CancellableTask', () => {
describe('execute', () => {
it('should execute task successfully and return LOADED', async () => {
it('should execute task successfully and return SUCCESS', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async (_: AbortSignal) => {
const taskFunction = vi.fn(async (_: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
const result = await task.execute(taskFn, true);
const result = await task.execute(taskFunction, true);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(task.loading).toBe(false);
expect(taskFn).toHaveBeenCalledTimes(1);
expect(result).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
expect(task.running).toBe(false);
expect(taskFunction).toHaveBeenCalledTimes(1);
});
it('should call loadedCallback when task completes successfully', async () => {
const loadedCallback = vi.fn();
const task = new CancellableTask(loadedCallback);
const taskFn = vi.fn(async () => {});
it('should call succeededCallback when task completes successfully', async () => {
const succeededCallback = vi.fn();
const task = new CancellableTask(succeededCallback);
const taskFunction = vi.fn(async () => {});
await task.execute(taskFn, true);
await task.execute(taskFunction, true);
expect(loadedCallback).toHaveBeenCalledTimes(1);
expect(succeededCallback).toHaveBeenCalledTimes(1);
});
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const taskFunction = vi.fn(async () => {});
await task.execute(taskFn, true);
const result = await task.execute(taskFn, true);
await task.execute(taskFunction, true);
const result = await task.execute(taskFunction, true);
expect(result).toBe('DONE');
expect(taskFn).toHaveBeenCalledTimes(1);
expect(taskFunction).toHaveBeenCalledTimes(1);
});
it('should wait if task is already running', async () => {
@@ -43,42 +43,42 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = vi.fn(async () => {
const taskFunction = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFn, true);
const promise2 = task.execute(taskFn, true);
const promise1 = task.execute(taskFunction, true);
const promise2 = task.execute(taskFunction, true);
expect(task.loading).toBe(true);
expect(task.running).toBe(true);
resolveTask!();
const [result1, result2] = await Promise.all([promise1, promise2]);
expect(result1).toBe('LOADED');
expect(result1).toBe('SUCCESS');
expect(result2).toBe('WAITED');
expect(taskFn).toHaveBeenCalledTimes(1);
expect(taskFunction).toHaveBeenCalledTimes(1);
});
it('should pass AbortSignal to task function', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFn = async (signal: AbortSignal) => {
const taskFunction = async (signal: AbortSignal) => {
await Promise.resolve();
capturedSignal = signal;
};
await task.execute(taskFn, true);
await task.execute(taskFunction, true);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
it('should set cancellable flag correctly', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const taskFunction = vi.fn(async () => {});
expect(task.cancellable).toBe(true);
const promise = task.execute(taskFn, false);
const promise = task.execute(taskFunction, false);
expect(task.cancellable).toBe(false);
await promise;
});
@@ -89,14 +89,14 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = vi.fn(async () => {
const taskFunction = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFn, false);
const promise1 = task.execute(taskFunction, false);
expect(task.cancellable).toBe(false);
const promise2 = task.execute(taskFn, true);
const promise2 = task.execute(taskFunction, true);
expect(task.cancellable).toBe(false);
resolveTask!();
@@ -108,7 +108,7 @@ describe('CancellableTask', () => {
it('should cancel a running task', async () => {
const task = new CancellableTask();
let taskStarted = false;
const taskFn = async (signal: AbortSignal) => {
const taskFunction = async (signal: AbortSignal) => {
taskStarted = true;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
@@ -116,9 +116,7 @@ describe('CancellableTask', () => {
}
};
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
const promise = task.execute(taskFunction, true);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(taskStarted).toBe(true);
@@ -126,20 +124,20 @@ describe('CancellableTask', () => {
const result = await promise;
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
expect(task.succeeded).toBe(false);
});
it('should call canceledCallback when task is canceled', async () => {
const canceledCallback = vi.fn();
const task = new CancellableTask(undefined, canceledCallback);
const taskFn = async (signal: AbortSignal) => {
const taskFunction = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
const promise = task.execute(taskFunction, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
@@ -149,55 +147,79 @@ describe('CancellableTask', () => {
it('should not cancel if task is not cancellable', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {
const taskFunction = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
const promise = task.execute(taskFn, false);
const promise = task.execute(taskFunction, false);
task.cancel();
const result = await promise;
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(result).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
});
it('should return CANCELED when concurrent caller is waiting and task is canceled', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFunction = async (signal: AbortSignal) => {
await taskPromise;
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise1 = task.execute(taskFunction, true);
const promise2 = task.execute(taskFunction, true);
task.cancel();
resolveTask!();
const [result1, result2] = await Promise.all([promise1, promise2]);
expect(result1).toBe('CANCELED');
expect(result2).toBe('CANCELED');
});
it('should not cancel if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const taskFunction = vi.fn(async () => {});
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
await task.execute(taskFunction, true);
expect(task.succeeded).toBe(true);
task.cancel();
expect(task.executed).toBe(true);
expect(task.succeeded).toBe(true);
});
});
describe('reset', () => {
it('should reset task to initial state', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const taskFunction = vi.fn(async () => {});
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
await task.execute(taskFunction, true);
expect(task.succeeded).toBe(true);
await task.reset();
expect(task.executed).toBe(false);
expect(task.cancelToken).toBe(null);
expect(task.loading).toBe(false);
expect(task.succeeded).toBe(false);
expect(task.abortController).toBe(null);
expect(task.running).toBe(false);
});
it('should cancel running task before resetting', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
const taskFunction = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
const promise = task.execute(taskFunction, true);
await new Promise((resolve) => setTimeout(resolve, 10));
const resetPromise = task.reset();
@@ -205,30 +227,30 @@ describe('CancellableTask', () => {
await promise;
await resetPromise;
expect(task.executed).toBe(false);
expect(task.loading).toBe(false);
expect(task.succeeded).toBe(false);
expect(task.running).toBe(false);
});
it('should allow re-execution after reset', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const taskFunction = vi.fn(async () => {});
await task.execute(taskFn, true);
await task.execute(taskFunction, true);
await task.reset();
const result = await task.execute(taskFn, true);
const result = await task.execute(taskFunction, true);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(taskFn).toHaveBeenCalledTimes(2);
expect(result).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
expect(taskFunction).toHaveBeenCalledTimes(2);
});
});
describe('waitUntilCompletion', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const taskFunction = vi.fn(async () => {});
await task.execute(taskFn, true);
await task.execute(taskFunction, true);
const result = await task.waitUntilCompletion();
expect(result).toBe('DONE');
@@ -240,11 +262,11 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = async () => {
const taskFunction = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFn, true);
const executePromise = task.execute(taskFunction, true);
const waitPromise = task.waitUntilCompletion();
resolveTask!();
@@ -256,14 +278,14 @@ describe('CancellableTask', () => {
it('should return CANCELED if task is canceled', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
const taskFunction = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const executePromise = task.execute(taskFn, true);
const executePromise = task.execute(taskFunction, true);
const waitPromise = task.waitUntilCompletion();
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -275,13 +297,13 @@ describe('CancellableTask', () => {
});
});
describe('waitUntilExecution', () => {
describe('waitUntilSucceeded', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const taskFunction = vi.fn(async () => {});
await task.execute(taskFn, true);
const result = await task.waitUntilExecution();
await task.execute(taskFunction, true);
const result = await task.waitUntilSucceeded();
expect(result).toBe('DONE');
});
@@ -292,12 +314,12 @@ describe('CancellableTask', () => {
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = async () => {
const taskFunction = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
const executePromise = task.execute(taskFunction, true);
const waitPromise = task.waitUntilSucceeded();
resolveTask!();
@@ -311,7 +333,7 @@ describe('CancellableTask', () => {
const task = new CancellableTask();
let attempt = 0;
const taskFn = async (signal: AbortSignal) => {
const taskFunction = async (signal: AbortSignal) => {
attempt++;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted && attempt === 1) {
@@ -320,8 +342,8 @@ describe('CancellableTask', () => {
};
// Start first execution
const executePromise1 = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
const executePromise1 = task.execute(taskFunction, true);
const waitPromise = task.waitUntilSucceeded();
// Cancel the first execution
vi.advanceTimersByTime(10);
@@ -330,12 +352,12 @@ describe('CancellableTask', () => {
await executePromise1;
// Start second execution
const executePromise2 = task.execute(taskFn, true);
const executePromise2 = task.execute(taskFunction, true);
vi.advanceTimersByTime(100);
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
expect(executeResult).toBe('LOADED');
expect(executeResult).toBe('SUCCESS');
expect(waitResult).toBe('WAITED');
expect(attempt).toBe(2);
@@ -347,98 +369,98 @@ describe('CancellableTask', () => {
it('should return ERRORED when task throws non-abort error', async () => {
const task = new CancellableTask();
const error = new Error('Task failed');
const taskFn = async () => {
const taskFunction = async () => {
await Promise.resolve();
throw error;
};
const result = await task.execute(taskFn, true);
const result = await task.execute(taskFunction, true);
expect(result).toBe('ERRORED');
expect(task.executed).toBe(false);
expect(task.succeeded).toBe(false);
});
it('should call errorCallback when task throws non-abort error', async () => {
const errorCallback = vi.fn();
const task = new CancellableTask(undefined, undefined, errorCallback);
const error = new Error('Task failed');
const taskFn = async () => {
const taskFunction = async () => {
await Promise.resolve();
throw error;
};
await task.execute(taskFn, true);
await task.execute(taskFunction, true);
expect(errorCallback).toHaveBeenCalledTimes(1);
expect(errorCallback).toHaveBeenCalledWith(error);
});
it('should return CANCELED when task throws AbortError', async () => {
it('should return ERRORED when task throws AbortError without signal being aborted', async () => {
const task = new CancellableTask();
const taskFn = async () => {
const taskFunction = async () => {
await Promise.resolve();
throw new DOMException('Aborted', 'AbortError');
};
const result = await task.execute(taskFn, true);
const result = await task.execute(taskFunction, true);
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
expect(result).toBe('ERRORED');
expect(task.succeeded).toBe(false);
});
it('should allow re-execution after error', async () => {
const task = new CancellableTask();
const taskFn1 = async () => {
const taskFunction1 = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const taskFn2 = vi.fn(async () => {});
const taskFunction2 = vi.fn(async () => {});
const result1 = await task.execute(taskFn1, true);
const result1 = await task.execute(taskFunction1, true);
expect(result1).toBe('ERRORED');
const result2 = await task.execute(taskFn2, true);
expect(result2).toBe('LOADED');
expect(task.executed).toBe(true);
const result2 = await task.execute(taskFunction2, true);
expect(result2).toBe('SUCCESS');
expect(task.succeeded).toBe(true);
});
});
describe('loading property', () => {
describe('running property', () => {
it('should return true when task is running', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = async () => {
const taskFunction = async () => {
await taskPromise;
};
expect(task.loading).toBe(false);
expect(task.running).toBe(false);
const promise = task.execute(taskFn, true);
expect(task.loading).toBe(true);
const promise = task.execute(taskFunction, true);
expect(task.running).toBe(true);
resolveTask!();
await promise;
expect(task.loading).toBe(false);
expect(task.running).toBe(false);
});
});
describe('complete promise', () => {
it('should resolve when task completes successfully', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const taskFunction = vi.fn(async () => {});
const completePromise = task.complete;
await task.execute(taskFn, true);
await task.execute(taskFunction, true);
await expect(completePromise).resolves.toBeUndefined();
});
it('should reject when task is canceled', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
const taskFunction = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
@@ -446,7 +468,7 @@ describe('CancellableTask', () => {
};
const completePromise = task.complete;
const promise = task.execute(taskFn, true);
const promise = task.execute(taskFunction, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
@@ -456,13 +478,13 @@ describe('CancellableTask', () => {
it('should reject when task errors', async () => {
const task = new CancellableTask();
const taskFn = async () => {
const taskFunction = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const completePromise = task.complete;
await task.execute(taskFn, true);
await task.execute(taskFunction, true);
await expect(completePromise).rejects.toBeUndefined();
});
@@ -472,27 +494,22 @@ describe('CancellableTask', () => {
it('should automatically call abort() on signal when task is canceled', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFn = async (signal: AbortSignal) => {
const taskFunction = async (signal: AbortSignal) => {
capturedSignal = signal;
// Simulate a long-running task
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
const promise = task.execute(taskFunction, true);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(capturedSignal).not.toBeNull();
expect(capturedSignal!.aborted).toBe(false);
// Cancel the task
task.cancel();
// Verify the signal was aborted
expect(capturedSignal!.aborted).toBe(true);
const result = await promise;
@@ -502,25 +519,22 @@ describe('CancellableTask', () => {
it('should detect if signal was aborted after task completes', async () => {
const task = new CancellableTask();
let controller: AbortController | null = null;
const taskFn = async (_: AbortSignal) => {
// Capture the controller to abort it externally
controller = task.cancelToken;
// Simulate some work
const taskFunction = async (_: AbortSignal) => {
// Capture the controller to abort it externally before the function returns
controller = task.abortController;
await new Promise((resolve) => setTimeout(resolve, 10));
// Now abort before the function returns
controller?.abort();
};
const result = await task.execute(taskFn, true);
const result = await task.execute(taskFunction, true);
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
expect(task.succeeded).toBe(false);
});
it('should handle abort signal in async operations', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
// Simulate listening to abort signal during async operation
const taskFunction = async (signal: AbortSignal) => {
return new Promise<void>((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
@@ -529,7 +543,7 @@ describe('CancellableTask', () => {
});
};
const promise = task.execute(taskFn, true);
const promise = task.execute(taskFunction, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();

View File

@@ -1,47 +1,60 @@
/**
* A one-shot async task with cancellation support via AbortController/AbortSignal.
*
* State machine:
*
* IDLE ──execute()──▶ RUNNING ──task succeeds──▶ SUCCEEDED (terminal)
* │
* ├──cancel()/abort──▶ CANCELED ──▶ IDLE
* └──task throws─────▶ ERRORED ──▶ IDLE
*
* SUCCEEDED is terminal — further execute() calls return 'DONE'.
* Call reset() to move from SUCCEEDED back to IDLE for re-execution.
*
* execute() return values: 'SUCCESS' | 'DONE' | 'WAITED' | 'CANCELED' | 'ERRORED'
*/
export class CancellableTask {
cancelToken: AbortController | null = null;
abortController: AbortController | null = null;
cancellable: boolean = true;
/**
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
* A promise that resolves once the task completes, and rejects if the task is canceled or errored.
*/
complete!: Promise<unknown>;
executed: boolean = false;
succeeded: boolean = false;
private loadedSignal: (() => void) | undefined;
private canceledSignal: (() => void) | undefined;
private completeResolve: (() => void) | undefined;
private completeReject: (() => void) | undefined;
constructor(
private loadedCallback?: () => void,
private succeededCallback?: () => void,
private canceledCallback?: () => void,
private errorCallback?: (error: unknown) => void,
) {
this.init();
}
get loading() {
return !!this.cancelToken;
get running() {
return !!this.abortController;
}
async waitUntilCompletion() {
if (this.executed) {
if (this.succeeded) {
return 'DONE';
}
// The `complete` promise resolves when executed, rejects when canceled/errored.
try {
const complete = this.complete;
await complete;
await this.complete;
return 'WAITED';
} catch {
// ignore
// expected when canceled
}
return 'CANCELED';
}
async waitUntilExecution() {
async waitUntilSucceeded() {
// Keep retrying until the task completes successfully (not canceled)
for (;;) {
try {
if (this.executed) {
if (this.succeeded) {
return 'DONE';
}
await this.complete;
@@ -52,59 +65,60 @@ export class CancellableTask {
}
}
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
if (this.executed) {
async execute(task: (abortSignal: AbortSignal) => Promise<void>, cancellable: boolean) {
if (this.succeeded) {
return 'DONE';
}
// if promise is pending, wait on previous request instead.
if (this.cancelToken) {
// if promise is pending, and preventCancel is requested,
// do not allow transition from prevent cancel to allow cancel.
if (this.cancellable && !cancellable) {
this.cancellable = cancellable;
if (this.abortController) {
if (!cancellable) {
this.cancellable = false;
}
await this.complete;
return 'WAITED';
}
this.cancellable = cancellable;
const cancelToken = (this.cancelToken = new AbortController());
try {
await f(cancelToken.signal);
if (cancelToken.signal.aborted) {
try {
await this.complete;
return 'WAITED';
} catch {
return 'CANCELED';
}
this.#transitionToExecuted();
return 'LOADED';
}
this.cancellable = cancellable;
const abortController = (this.abortController = new AbortController());
try {
await task(abortController.signal);
if (abortController.signal.aborted) {
return 'CANCELED';
}
this.#transitionToSucceeded();
return 'SUCCESS';
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).name === 'AbortError') {
// abort error is not treated as an error, but as a cancellation.
if (abortController.signal.aborted) {
return 'CANCELED';
}
this.#transitionToErrored(error);
return 'ERRORED';
} finally {
this.cancelToken = null;
if (this.abortController === abortController) {
this.abortController = null;
}
}
}
private init() {
this.abortController = null;
this.succeeded = false;
this.complete = new Promise<void>((resolve, reject) => {
this.cancelToken = null;
this.executed = false;
this.loadedSignal = resolve;
this.canceledSignal = reject;
this.completeResolve = resolve;
this.completeReject = reject;
});
// Suppress unhandled rejection warning
this.complete.catch(() => {});
}
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
async reset() {
this.#transitionToCancelled();
if (this.cancelToken) {
if (this.abortController) {
await this.waitUntilCompletion();
}
this.init();
@@ -115,27 +129,26 @@ export class CancellableTask {
}
#transitionToCancelled() {
if (this.executed) {
if (this.succeeded) {
return;
}
if (!this.cancellable) {
return;
}
this.cancelToken?.abort();
this.canceledSignal?.();
this.abortController?.abort();
this.completeReject?.();
this.init();
this.canceledCallback?.();
}
#transitionToExecuted() {
this.executed = true;
this.loadedSignal?.();
this.loadedCallback?.();
#transitionToSucceeded() {
this.succeeded = true;
this.completeResolve?.();
this.succeededCallback?.();
}
#transitionToErrored(error: unknown) {
this.cancelToken = null;
this.canceledSignal?.();
this.completeReject?.();
this.init();
this.errorCallback?.(error);
}