From 8f477437af69bddd936eb76cfd238752462e104f Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 26 Sep 2020 01:22:05 +0200 Subject: [PATCH] Updated the icon API --- shared/js/file/FileManager.tsx | 14 +- shared/js/file/Icons.ts | 189 +++++++++++++++ shared/js/file/Icons.tsx | 412 --------------------------------- shared/js/file/ImageCache.ts | 8 +- shared/js/file/LocalAvatars.ts | 25 +- shared/js/file/LocalIcons.ts | 274 ++++++++++++++++++++++ 6 files changed, 490 insertions(+), 432 deletions(-) create mode 100644 shared/js/file/Icons.ts delete mode 100644 shared/js/file/Icons.tsx create mode 100644 shared/js/file/LocalIcons.ts diff --git a/shared/js/file/FileManager.tsx b/shared/js/file/FileManager.tsx index c0a10bea..de941250 100644 --- a/shared/js/file/FileManager.tsx +++ b/shared/js/file/FileManager.tsx @@ -4,7 +4,6 @@ import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {ServerCommand} from "tc-shared/connection/ConnectionBase"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler"; -import {IconManager} from "tc-shared/file/Icons"; import {AvatarManager} from "tc-shared/file/LocalAvatars"; import { CancelReason, @@ -393,7 +392,6 @@ export class FileManager { private static readonly MAX_CONCURRENT_TRANSFERS = 6; readonly connectionHandler: ConnectionHandler; - readonly icons: IconManager; readonly avatars: AvatarManager; readonly events : Registry; readonly finishedTransfers: FinishedFileTransfer[] = []; @@ -409,7 +407,6 @@ export class FileManager { this.commandHandler = new FileCommandHandler(this); this.events = new Registry(); - this.icons = new IconManager(this); this.avatars = new AvatarManager(this); this.transerUpdateIntervalId = setInterval(() => this.scheduleTransferUpdate(), 1000); @@ -420,7 +417,6 @@ export class FileManager { this.registeredTransfers_.forEach(e => e.transfer.requestCancel(CancelReason.SERVER_DISCONNECTED)); /* all transfers should be unregistered now, or will be soonly */ - this.icons.destroy(); this.avatars.destroy(); clearInterval(this.transerUpdateIntervalId); } @@ -503,6 +499,16 @@ export class FileManager { }); } + async deleteIcon(iconId: number) : Promise { + if(iconId <= 1000) { + throw "invalid id!"; + } + + await this.deleteFile({ + name: '/icon_' + (iconId >>> 0) + }); + } + registeredTransfers() : FileTransfer[] { return this.registeredTransfers_.map(e => e.transfer); } diff --git a/shared/js/file/Icons.ts b/shared/js/file/Icons.ts new file mode 100644 index 00000000..9ea34319 --- /dev/null +++ b/shared/js/file/Icons.ts @@ -0,0 +1,189 @@ +import {Registry} from "tc-shared/events"; + +export const kIPCIconChannel = "avatars"; +export const kGlobalIconHandlerId = "global"; + +export interface RemoteIconEvents { + notify_state_changed: { oldState: RemoteIconState, newState: RemoteIconState } +} + +export type RemoteIconState = "loading" | "loaded" | "error" | "empty" | "destroyed"; + +export type RemoteIconInfo = { + iconId: number, + serverUniqueId: string, + handlerId?: string +} + +export abstract class RemoteIcon { + readonly events: Registry; + readonly iconId: number; + readonly serverUniqueId: string; + + private state: RemoteIconState; + + private imageUrl: string; + private errorMessage: string; + + protected constructor(serverUniqueId: string, iconId: number) { + this.events = new Registry(); + this.iconId = iconId; + this.serverUniqueId = serverUniqueId; + + this.state = "loading"; + } + + destroy() { + this.setState("destroyed"); + this.events.destroy(); + this.imageUrl = undefined; + } + + getState() : RemoteIconState { + return this.state; + } + + protected setState(state: RemoteIconState) { + if(this.state === state) { + return; + } else if(this.state === "destroyed") { + throw tr("remote icon has been destroyed"); + } + + const oldState = this.state; + this.state = state; + this.events.fire("notify_state_changed", { newState: state, oldState: oldState }); + } + + /** + * Will throw an string if the icon isn't in loaded state + */ + getImageUrl() : string { + if(this.state !== "loaded") { + throw tr("icon image url is only available when the state is loaded"); + } + + if(!this.imageUrl) { + throw tr("remote icon is missing an image url"); + } + + return this.imageUrl; + } + + protected setImageUrl(url: string) { + if(this.imageUrl) { + throw tr("an image url has already been set"); + } + + this.imageUrl = url; + } + + /** + * Will throw an string if the state isn't error + */ + getErrorMessage() : string | undefined { + if(this.state !== "error") { + throw tr("invalid remote icon state, expected error"); + } + + return this.errorMessage; + } + + protected setErrorMessage(message: string) { + this.errorMessage = message; + } + + /** + * Waits 'till the icon has been loaded or any other, non loading, state has been reached. + */ + async awaitLoaded() { + while(!this.isLoaded()) { + await new Promise(resolve => this.events.one("notify_state_changed", resolve)); + } + } + + /** + * Returns true if the icon isn't loading any more. + * This includes all other states like error, destroy or empty. + */ + isLoaded() : boolean { + return this.state !== "loading"; + } +} + +export abstract class AbstractIconManager { + /** + * @param iconId The requested icon + * @param serverUniqueId The server unique id for the icon + * @param handlerId Hint which connection handler should be used if we're downloading the icon + */ + abstract resolveIcon(iconId: number, serverUniqueId: string, handlerId?: string) : RemoteIcon; +} + +let globalIconManager: AbstractIconManager; +export function setIconManager(instance: AbstractIconManager) { + if(globalIconManager) { + throw "the global icon manager has already been set"; + } + + globalIconManager = instance; +} + +export function getIconManager() { + return globalIconManager; +} + + +/* a helper for legacy code */ +export function generateIconJQueryTag(icon: RemoteIcon | undefined, options?: { animate?: boolean }) : JQuery { + options = options || {}; + + let icon_container = $.spawn("div").addClass("icon-container icon_empty"); + let icon_load_image = $.spawn("div").addClass("icon_loading"); + + const icon_image = $.spawn("img").attr("width", 16).attr("height", 16).attr("alt", ""); + + if (icon.iconId == 0) { + icon_load_image = undefined; + } else if (icon.iconId < 1000) { + icon_load_image = undefined; + icon_container.removeClass("icon_empty").addClass("icon_em client-group_" + icon.iconId); + } else { + const loading_done = sync => {//TODO: Show error? + if (icon.getState() === "empty") { + icon_load_image.remove(); + icon_load_image = undefined; + } else if (icon.getState() === "error") { + //TODO: Error icon? + icon_load_image.remove(); + icon_load_image = undefined; + } else { + icon_image.attr("src", icon.getImageUrl()); + icon_container.append(icon_image).removeClass("icon_empty"); + + if (!sync && (typeof (options.animate) !== "boolean" || options.animate)) { + icon_image.css("opacity", 0); + + icon_load_image.animate({opacity: 0}, 50, function () { + icon_load_image.remove(); + icon_image.animate({opacity: 1}, 150); + }); + } else { + icon_load_image.remove(); + icon_load_image = undefined; + } + } + }; + + if(icon.isLoaded()) { + loading_done(true); + } else { + icon.awaitLoaded().then(() => loading_done(false)); + } + } + + if (icon_load_image) { + icon_load_image.appendTo(icon_container); + } + return icon_container; +} \ No newline at end of file diff --git a/shared/js/file/Icons.tsx b/shared/js/file/Icons.tsx deleted file mode 100644 index ed2b3224..00000000 --- a/shared/js/file/Icons.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import * as log from "tc-shared/log"; -import {LogCategory} from "tc-shared/log"; -import {Registry} from "tc-shared/events"; -import {format_time} from "tc-shared/ui/frames/chat"; -import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; -import {image_type, ImageCache, ImageType, media_image_type} from "tc-shared/file/ImageCache"; -import {FileInfo, FileManager} from "tc-shared/file/FileManager"; -import { - FileDownloadTransfer, - FileTransferState, ResponseTransferTarget, TransferProvider, - TransferTargetType -} from "tc-shared/file/Transfer"; -import {tr} from "tc-shared/i18n/localize"; -import {ErrorCode} from "tc-shared/connection/ErrorCode"; -import {server_connections} from "tc-shared/ConnectionManager"; - -const icon_cache: ImageCache = new ImageCache("icons"); -export interface IconManagerEvents { - notify_icon_state_changed: { - icon_id: number, - server_unique_id: string, - - icon: LocalIcon - }, -} - -//TODO: Invalidate icon after certain time if loading has failed and try to redownload (only if an icon loader has been set!) -export type IconLoader = (icon?: LocalIcon) => Promise; -export class LocalIcon { - readonly icon_id: number; - readonly server_unique_id: string; - readonly status_change_callbacks: ((icon?: LocalIcon) => void)[] = []; - - status: "loading" | "loaded" | "empty" | "error" | "destroyed"; - - loaded_url?: string; - error_message?: string; - - private callback_icon_loader: IconLoader; - - constructor(id: number, server: string, loader_or_response: Response | IconLoader | undefined) { - this.icon_id = id; - this.server_unique_id = server; - - if(id >= 0 && id <= 1000) { - /* Internal TeaSpeak icons. These must be handled differently! */ - this.status = "loaded"; - } else { - this.status = "loading"; - if(loader_or_response instanceof Response) { - this.set_image(loader_or_response).catch(error => { - log.error(LogCategory.GENERAL, tr("Icon set image method threw an unexpected error: %o"), error); - this.status = "error"; - this.error_message = "unexpected parse error"; - this.triggerStatusChange(); - }); - } else { - this.callback_icon_loader = loader_or_response; - this.load().catch(error => { - log.error(LogCategory.GENERAL, tr("Icon load method threw an unexpected error: %o"), error); - this.status = "error"; - this.error_message = "unexpected load error"; - this.triggerStatusChange(); - }).then(() => { - this.callback_icon_loader = undefined; /* release resources captured by possible closures */ - }); - } - } - } - - private triggerStatusChange() { - for(const lister of this.status_change_callbacks.slice(0)) - lister(this); - } - - /* called within the CachedIconManager */ - protected destroy() { - if(typeof this.loaded_url === "string" && URL.revokeObjectURL) - URL.revokeObjectURL(this.loaded_url); - - this.status = "destroyed"; - this.loaded_url = undefined; - this.error_message = undefined; - - this.triggerStatusChange(); - this.status_change_callbacks.splice(0, this.status_change_callbacks.length); - } - - private async load() { - if(!icon_cache.setupped()) - await icon_cache.setup(); - - let response = await icon_cache.resolve_cached("icon_" + this.server_unique_id + "_" + this.icon_id); //TODO age! - if(!response) { - if(typeof this.callback_icon_loader !== "function") { - this.status = "empty"; - this.triggerStatusChange(); - return; - } - - try { - response = await this.callback_icon_loader(this); - } catch (error) { - log.warn(LogCategory.GENERAL, tr("Failed to download icon %d: %o"), this.icon_id, error); - await this.set_error(typeof error === "string" ? error : tr("Failed to load icon")); - return; - } - try { - await this.set_image(response); - } catch (error) { - log.error(LogCategory.GENERAL, tr("Failed to update icon image for icon %d: %o"), this.icon_id, error); - await this.set_error(typeof error === "string" ? error : tr("Failed to update icon from downloaded file")); - return; - } - return; - } - - this.loaded_url = await response_to_url(response); - this.status = "loaded"; - this.triggerStatusChange(); - } - - async set_image(response: Response) { - if(this.icon_id >= 0 && this.icon_id <= 1000) throw "Could not set image for internal icon"; - - const type = image_type(response.headers.get('X-media-bytes')); - if(type === ImageType.UNKNOWN) throw "unknown image type"; - - const media = media_image_type(type); - await icon_cache.put_cache("icon_" + this.server_unique_id + "_" + this.icon_id, response.clone(), "image/" + media); - - this.loaded_url = await response_to_url(response); - this.status = "loaded"; - this.triggerStatusChange(); - } - - set_error(error: string) { - if(this.status === "loaded" || this.status === "destroyed") return; - if(this.status === "error" && this.error_message === error) return; - this.status = "error"; - this.error_message = error; - this.triggerStatusChange(); - } - - async await_loading() { - await new Promise(resolve => { - if(this.status !== "loading") { - resolve(); - return; - } - const callback = () => { - if(this.status === "loading") return; - - this.status_change_callbacks.remove(callback); - resolve(); - }; - this.status_change_callbacks.push(callback); - }) - } -} - -async function response_to_url(response: Response) { - if(!response.headers.has('X-media-bytes')) - throw "missing media bytes"; - - const type = image_type(response.headers.get('X-media-bytes')); - const media = media_image_type(type); - - const blob = await response.blob(); - if(blob.type !== "image/" + media) - return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media)); - else - return URL.createObjectURL(blob) -} - -class CachedIconManager { - private loaded_icons: {[id: string]:LocalIcon} = {}; - - async clear_cache() { - await icon_cache.reset(); - this.clear_memory_cache(); - } - - clear_memory_cache() { - for(const icon_id of Object.keys(this.loaded_icons)) - this.loaded_icons[icon_id]["destroy"](); - this.loaded_icons = {}; - } - - load_icon(id: number, server_unique_id: string, fallback_load?: IconLoader) : LocalIcon { - const cache_id = server_unique_id + "_" + (id >>> 0); - if(this.loaded_icons[cache_id]) return this.loaded_icons[cache_id]; - - return (this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, fallback_load)); - } - - async put_icon(id: number, server_unique_id: string, icon: Response) { - const cache_id = server_unique_id + "_" + (id >>> 0); - if(this.loaded_icons[cache_id]) - await this.loaded_icons[cache_id].set_image(icon); - else { - const licon = this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, icon); - await new Promise((resolve, reject) => { - const cb = () => { - licon.status_change_callbacks.remove(cb); - if(licon.status === "loaded") - resolve(); - else - reject(licon.status === "error" ? licon.error_message || tr("Unknown error") : tr("Invalid status")); - }; - - licon.status_change_callbacks.push(cb); - }) - } - } -} -export const icon_cache_loader = new CachedIconManager(); -window.addEventListener("beforeunload", () => { - icon_cache_loader.clear_memory_cache(); -}); - -(window as any).flush_icon_cache = async () => { - icon_cache_loader.clear_memory_cache(); - await icon_cache_loader.clear_cache(); - - server_connections.all_connections().forEach(e => { - e.fileManager.icons.flush_cache(); - }); -}; - -type IconManagerLoadingData = { - result: "success" | "error" | "unset"; - next_retry?: number; - error?: string; -} -export class IconManager { - handle: FileManager; - readonly events: Registry; - private loading_timestamps: {[key: number]: IconManagerLoadingData} = {}; - - constructor(handle: FileManager) { - this.handle = handle; - this.events = new Registry(); - } - - destroy() { - this.loading_timestamps = {}; - } - - async delete_icon(id: number) : Promise { - if(id <= 1000) - throw "invalid id!"; - - await this.handle.deleteFile({ - name: '/icon_' + id - }); - } - - iconList() : Promise { - return this.handle.requestFileList("/icons"); - } - - createIconDownload(id: number) : FileDownloadTransfer { - return this.handle.initializeFileDownload({ - path: "", - name: "/icon_" + id, - targetSupplier: async () => await TransferProvider.provider().createResponseTarget() - }); - } - - private async server_icon_loader(icon: LocalIcon) : Promise { - const loading_data: IconManagerLoadingData = this.loading_timestamps[icon.icon_id] || (this.loading_timestamps[icon.icon_id] = { result: "unset" }); - if(loading_data.result === "error") { - if(!loading_data.next_retry || loading_data.next_retry > Date.now()) { - log.debug(LogCategory.GENERAL, tr("Don't retry icon download from server. We'll try again in %s"), - !loading_data.next_retry ? tr("never") : format_time(loading_data.next_retry - Date.now(), tr("1 second"))); - throw loading_data.error; - } - } - - try { - let transfer = this.createIconDownload(icon.icon_id); - - try { - await transfer.awaitFinished(); - - if(transfer.transferState() === FileTransferState.CANCELED) { - throw tr("download canceled"); - } else if(transfer.transferState() === FileTransferState.ERRORED) { - throw transfer.currentError(); - } else if(transfer.transferState() === FileTransferState.FINISHED) { - - } else { - throw tr("Unknown transfer finished state"); - } - } catch(error) { - if(error instanceof CommandResult) { - if(error.id === ErrorCode.FILE_NOT_FOUND) - throw tr("Icon could not be found"); - else if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) - throw tr("No permissions to download icon"); - else - throw error.extra_message || error.message; - } - log.error(LogCategory.CLIENT, tr("Could not request download for icon %d: %o"), icon.icon_id, error); - if(error === transfer.currentError()) - throw transfer.currentErrorMessage(); - throw typeof error === "string" ? error : tr("Failed to initialize icon download"); - } - - /* could only be tested here, because before we don't know which target we have */ - if(transfer.target.type !== TransferTargetType.RESPONSE) - throw "unsupported transfer target"; - - const response = transfer.target as ResponseTransferTarget; - if(!response.hasResponse()) - throw tr("Transfer has no response"); - - loading_data.result = "success"; - return response.getResponse(); - } catch (error) { - loading_data.result = "error"; - loading_data.error = error as string; - loading_data.next_retry = Date.now() + 300 * 1000; - throw error; - } - } - - static generate_tag(icon: LocalIcon | undefined, options?: { - animate?: boolean - }) : JQuery { - options = options || {}; - - let icon_container = $.spawn("div").addClass("icon-container icon_empty"); - let icon_load_image = $.spawn("div").addClass("icon_loading"); - - const icon_image = $.spawn("img").attr("width", 16).attr("height", 16).attr("alt", ""); - - if (icon.icon_id == 0) { - icon_load_image = undefined; - } else if (icon.icon_id < 1000) { - icon_load_image = undefined; - icon_container.removeClass("icon_empty").addClass("icon_em client-group_" + icon.icon_id); - } else { - const loading_done = sync => {//TODO: Show error? - if(icon.status === "empty") { - icon_load_image.remove(); - icon_load_image = undefined; - } else if(icon.status === "error") { - //TODO: Error icon? - icon_load_image.remove(); - icon_load_image = undefined; - } else { - icon_image.attr("src", icon.loaded_url); - icon_container.append(icon_image).removeClass("icon_empty"); - - if (!sync && (typeof (options.animate) !== "boolean" || options.animate)) { - icon_image.css("opacity", 0); - - icon_load_image.animate({opacity: 0}, 50, function () { - icon_load_image.remove(); - icon_image.animate({opacity: 1}, 150); - }); - } else { - icon_load_image.remove(); - icon_load_image = undefined; - } - } - }; - - if(icon.status !== "loading") - loading_done(true); - else { - const cb = () => { - if(icon.status === "loading") return; - - icon.status_change_callbacks.remove(cb); - loading_done(false); - }; - icon.status_change_callbacks.push(cb); - } - } - - if(icon_load_image) - icon_load_image.appendTo(icon_container); - return icon_container; - } - - generateTag(id: number, options?: { - animate?: boolean - }) : JQuery { - options = options || {}; - return IconManager.generate_tag(this.load_icon(id), options); - } - - load_icon(id: number) : LocalIcon { - const server_uid = this.handle.connectionHandler.channelTree.server.properties.virtualserver_unique_identifier; - let icon = icon_cache_loader.load_icon(id, server_uid, this.server_icon_loader.bind(this)); - if(icon.status !== "loading" && icon.status !== "loaded") { - this.server_icon_loader(icon).then(response => { - return icon.set_image(response); - }).catch(error => { - console.warn("Failed to update broken cached icon from server: %o", error); - }) - } - return icon; - } - - flush_cache() { - this.loading_timestamps = {}; - } -} \ No newline at end of file diff --git a/shared/js/file/ImageCache.ts b/shared/js/file/ImageCache.ts index c940873c..2da10996 100644 --- a/shared/js/file/ImageCache.ts +++ b/shared/js/file/ImageCache.ts @@ -9,7 +9,7 @@ export enum ImageType { JPEG } -export function media_image_type(type: ImageType, file?: boolean) { +export function imageType2MediaType(type: ImageType, file?: boolean) { switch (type) { case ImageType.BITMAP: return "bmp"; @@ -26,7 +26,7 @@ export function media_image_type(type: ImageType, file?: boolean) { } } -export function image_type(encoded_data: string | ArrayBuffer, base64_encoded?: boolean) { +export function responseImageType(encoded_data: string | ArrayBuffer, base64_encoded?: boolean) { const ab2str10 = () => { const buf = new Uint8Array(encoded_data as ArrayBuffer); if(buf.byteLength < 10) @@ -97,7 +97,7 @@ export class ImageCache { /* FIXME: TODO */ } - async resolve_cached(key: string, max_age?: number) : Promise { + async resolveCached(key: string, max_age?: number) : Promise { max_age = typeof(max_age) === "number" ? max_age : -1; const cached_response = await this._cache_category.match("https://_local_cache/cache_request_" + key); @@ -108,7 +108,7 @@ export class ImageCache { return cached_response; } - async put_cache(key: string, value: Response, type?: string, headers?: {[key: string]:string}) { + async putCache(key: string, value: Response, type?: string, headers?: {[key: string]:string}) { const new_headers = new Headers(); for(const key of value.headers.keys()) new_headers.set(key, value.headers.get(key)); diff --git a/shared/js/file/LocalAvatars.ts b/shared/js/file/LocalAvatars.ts index b8b45b84..454815fb 100644 --- a/shared/js/file/LocalAvatars.ts +++ b/shared/js/file/LocalAvatars.ts @@ -4,7 +4,7 @@ import * as ipc from "../ipc/BrowserIPC"; import {ChannelMessage} from "../ipc/BrowserIPC"; import * as loader from "tc-loader"; import {Stage} from "tc-loader"; -import {image_type, ImageCache, media_image_type} from "../file/ImageCache"; +import {responseImageType, ImageCache, imageType2MediaType} from "../file/ImageCache"; import {FileManager} from "../file/FileManager"; import { FileDownloadTransfer, @@ -43,17 +43,18 @@ class LocalClientAvatar extends ClientAvatar { } export class AvatarManager extends AbstractAvatarManager { - handle: FileManager; private static cache: ImageCache; - + readonly handle: FileManager; private cachedAvatars: {[avatarId: string]: LocalClientAvatar} = {}; + constructor(handle: FileManager) { super(); this.handle = handle; - if(!AvatarManager.cache) + if(!AvatarManager.cache) { AvatarManager.cache = new ImageCache("avatars"); + } } destroy() { @@ -86,7 +87,7 @@ export class AvatarManager extends AbstractAvatarManager { await AvatarManager.cache.setup(); } - const response = await AvatarManager.cache.resolve_cached('avatar_' + avatar.clientAvatarId); //TODO age! + const response = await AvatarManager.cache.resolveCached('avatar_' + avatar.clientAvatarId); //TODO age! if(!response) { break cache_lookup; } @@ -165,15 +166,15 @@ export class AvatarManager extends AbstractAvatarManager { throw tr("Avatar response missing media bytes"); } - const type = image_type(headers.get('X-media-bytes')); - const media = media_image_type(type); + const type = responseImageType(headers.get('X-media-bytes')); + const media = imageType2MediaType(type); if(avatar.getAvatarHash() !== initialAvatarHash) { log.debug(LogCategory.GENERAL, tr("Ignoring avatar not found since the avatar itself got updated. Out version: %s, current version: %s"), initialAvatarHash, avatar.getAvatarHash()); return; } - await AvatarManager.cache.put_cache('avatar_' + avatar.clientAvatarId, transferResponse.getResponse().clone(), "image/" + media, { + await AvatarManager.cache.putCache('avatar_' + avatar.clientAvatarId, transferResponse.getResponse().clone(), "image/" + media, { "X-avatar-version": avatar.getAvatarHash() }); @@ -189,8 +190,8 @@ export class AvatarManager extends AbstractAvatarManager { if(!avatarResponse.headers.has('X-media-bytes')) throw "missing media bytes"; - const type = image_type(avatarResponse.headers.get('X-media-bytes')); - const media = media_image_type(type); + const type = responseImageType(avatarResponse.headers.get('X-media-bytes')); + const media = imageType2MediaType(type); const blob = await avatarResponse.blob(); @@ -240,7 +241,7 @@ export class AvatarManager extends AbstractAvatarManager { log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), cached.getAvatarHash(), clientAvatarHash); } - const response = await AvatarManager.cache.resolve_cached('avatar_' + clientAvatarId); + const response = await AvatarManager.cache.resolveCached('avatar_' + clientAvatarId); if(response) { let cachedAvatarHash = response.headers.has("X-avatar-version") ? response.headers.get("X-avatar-version") : undefined; if(cachedAvatarHash !== clientAvatarHash) { @@ -353,7 +354,7 @@ export class AvatarManager extends AbstractAvatarManager { }); }; -/* FIXME: unsubscribe if the other client isn't alive any mnore */ +/* FIXME: unsubscribe if the other client isn't alive any anymore */ class LocalAvatarManagerFactory extends AbstractAvatarManagerFactory { private ipcChannel: IPCChannel; diff --git a/shared/js/file/LocalIcons.ts b/shared/js/file/LocalIcons.ts new file mode 100644 index 00000000..59b783d7 --- /dev/null +++ b/shared/js/file/LocalIcons.ts @@ -0,0 +1,274 @@ +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; +import {ImageCache, ImageType, imageType2MediaType, responseImageType} from "tc-shared/file/ImageCache"; +import {AbstractIconManager, RemoteIcon, RemoteIconState, setIconManager} from "tc-shared/file/Icons"; +import * as log from "tc-shared/log"; +import {LogCategory, logDebug, logError, logWarn} from "tc-shared/log"; +import {server_connections} from "tc-shared/ConnectionManager"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {FileTransferState, ResponseTransferTarget, TransferProvider, TransferTargetType} from "tc-shared/file/Transfer"; +import {tr} from "tc-shared/i18n/localize"; +import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {ErrorCode} from "tc-shared/connection/ErrorCode"; + +/* TODO: Retry icon download after some time */ +/* TODO: Download icon when we're connected to the server were we want the icon from and update the icon */ + +async function responseToImageUrl(response: Response) : Promise { + if(!response.headers.has('X-media-bytes')) { + throw "missing media bytes"; + } + + const type = responseImageType(response.headers.get('X-media-bytes')); + if(type === ImageType.UNKNOWN) { + throw "unknown image type"; + } + + const media = imageType2MediaType(type); + const blob = await response.blob(); + if(blob.type !== "image/" + media) { + return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media)); + } else { + return URL.createObjectURL(blob) + } +} + +class LocalRemoteIcon extends RemoteIcon { + constructor(serverUniqueId: string, iconId: number) { + super(serverUniqueId, iconId); + } + + public setImageUrl(url: string) { + super.setImageUrl(url); + } + + public setErrorMessage(message: string) { + super.setErrorMessage(message); + } + + public setState(state: RemoteIconState) { + super.setState(state); + } +} + +export let localIconCache: ImageCache; +class IconManager extends AbstractIconManager { + private cachedIcons: {[key: string]: LocalRemoteIcon} = {}; + + private static iconUniqueKey(iconId: number, serverUniqueId: string) : string { + return "v2-" + serverUniqueId + "-" + iconId; + } + + resolveIcon(iconId: number, serverUniqueId: string, handlerIdHint: string): RemoteIcon { + /* just to ensure */ + iconId = iconId >>> 0; + + const iconUniqueId = IconManager.iconUniqueKey(iconId, serverUniqueId); + if(this.cachedIcons[iconUniqueId]) { + return this.cachedIcons[iconUniqueId]; + } + + let icon = new LocalRemoteIcon(serverUniqueId, iconId); + this.cachedIcons[iconUniqueId] = icon; + + if(iconId >= 0 && iconId <= 1000) { + icon.setState("loaded"); + } else { + this.loadIcon(icon, iconUniqueId, handlerIdHint).catch(error => { + if(typeof error !== "string") { + logError(LogCategory.FILE_TRANSFER, tr("Failed to load icon %d (%s): %o"), iconId, serverUniqueId, error); + icon.setErrorMessage(tr("load error, lookup the console")); + } else{ + icon.setErrorMessage(error); + icon.setState("error"); + } + }); + } + + return icon; + } + + private async loadIcon(icon: LocalRemoteIcon, iconUniqueId: string, handlerIdHint: string) { + /* try to load the icon from the local cache */ + const localCache = await localIconCache.resolveCached(iconUniqueId); + if(localCache) { + try { + const url = await responseToImageUrl(localCache); + icon.setImageUrl(url); + icon.setState("loaded"); + logDebug(LogCategory.FILE_TRANSFER, tr("Loaded icon %d (%s) from local cache."), icon.iconId, icon.serverUniqueId); + return; + } catch (error) { + logWarn(LogCategory.FILE_TRANSFER, tr("Failed to decode locally cached icon %d (%s): %o. Invalidating cache."), icon.iconId, icon.serverUniqueId, error); + try { + await localIconCache.delete(iconUniqueId); + } catch (error) { + logWarn(LogCategory.FILE_TRANSFER, tr("Failed to delete invalid key from icon cache (%s): %o"), iconUniqueId, error); + } + } + } + + /* try to fetch the icon from the server, if we're connected */ + let handler = server_connections.findConnection(handlerIdHint); + if(handler) { + if(!handler.connected) { + logWarn(LogCategory.FILE_TRANSFER, tr("Received handler id hint for icon download, but handler %s is not connected. Trying others."), handlerIdHint); + handler = undefined; + } else if(handler.channelTree.server.properties.virtualserver_unique_identifier !== icon.serverUniqueId) { + logWarn(LogCategory.FILE_TRANSFER, + tr("Received handler id hint for icon download, but handler %s is not connected to the expected server (%s <=> %s). Trying others."), + handlerIdHint, handler.channelTree.server.properties.virtualserver_unique_identifier, icon.serverUniqueId); + handler = undefined; + } else { + logDebug(LogCategory.FILE_TRANSFER, tr("Icon %s (%s) not found locally but the suggested handler is connected to the server. Downloading icon."), icon.iconId, icon.serverUniqueId); + } + + /* we don't want any "handler not found" warning */ + handlerIdHint = undefined; + } + + if(!handler) { + if(handlerIdHint) { + logWarn(LogCategory.FILE_TRANSFER, tr("Received handler id hint for icon download, but handler %s does not exists. Trying others."), handlerIdHint); + } + + const connections = server_connections.all_connections() + .filter(handler => handler.connected) + .filter(handler => handler.channelTree.server.properties.virtualserver_unique_identifier === icon.serverUniqueId); + + if(connections.length === 0) { + logDebug(LogCategory.FILE_TRANSFER, tr("Icon %s (%s) not found locally and we're currently not connected to the target server. Returning an empty result."), icon.iconId, icon.serverUniqueId); + icon.setState("empty"); + return; + } + + logDebug(LogCategory.FILE_TRANSFER, tr("Icon %s (%s) not found locally but we're connected to the server, using first available connection (%s). Downloading icon."), icon.iconId, icon.serverUniqueId, connections[0].handlerId); + handler = connections[0]; + } + + try { + await this.downloadIcon(icon, handler, iconUniqueId); + } catch (error) { + if(typeof error !== "string") { + logError(LogCategory.FILE_TRANSFER, tr("Failed to download icon %d (%s) from %s: %o"), icon.iconId, icon.serverUniqueId, handler.handlerId, error); + error = tr("download failed, lookup the console"); + } + + icon.setErrorMessage(error); + icon.setState("error"); + return; + } + + if(icon.getState() === "loading") { + icon.setErrorMessage(tr("unexpected loading state")); + icon.setState("error"); + } + } + + private async downloadIcon(icon: LocalRemoteIcon, handler: ConnectionHandler, iconUniqueId: string) { + const transfer = handler.fileManager.initializeFileDownload({ + path: "", + name: "/icon_" + icon.iconId, + targetSupplier: async () => await TransferProvider.provider().createResponseTarget() + }); + + try { + await transfer.awaitFinished(); + + if(transfer.transferState() === FileTransferState.CANCELED) { + throw tr("download canceled"); + } else if(transfer.transferState() === FileTransferState.ERRORED) { + throw transfer.currentError(); + } else if(transfer.transferState() === FileTransferState.FINISHED) { + + } else { + throw tr("Unknown transfer finished state"); + } + } catch(error) { + if(error instanceof CommandResult) { + if(error.id === ErrorCode.FILE_NOT_FOUND) { + throw tr("Icon could not be found"); + } else if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { + throw tr("No permissions to download icon"); + } else { + throw error.extra_message || error.message; + } + } + log.error(LogCategory.FILE_TRANSFER, tr("Could not request download for icon %d: %o"), icon.iconId, error); + if(error === transfer.currentError()) { + throw transfer.currentErrorMessage(); + } + + throw typeof error === "string" ? error : tr("Failed to initialize icon download"); + } + + /* could only be tested here, because before we don't know which target we have */ + if(transfer.target.type !== TransferTargetType.RESPONSE) { + throw "unsupported transfer target"; + } + + const response = transfer.target as ResponseTransferTarget; + if(!response.hasResponse()) { + throw tr("Transfer has no response"); + } + + try { + const url = await responseToImageUrl(response.getResponse().clone()); + icon.setImageUrl(url); + icon.setState("loaded"); + } catch (error) { + if(typeof error !== "string") { + logError(LogCategory.FILE_TRANSFER, tr("Failed to convert downloaded icon %d (%s) into an url: %o"), icon.iconId, icon.serverUniqueId, error); + error = tr("download failed, lookup the console"); + } + + icon.setErrorMessage(error); + icon.setState("error"); + return; + } + + try { + const resp = response.getResponse(); + if(!resp.headers.has('X-media-bytes')) { + throw "missing media bytes"; + } + + const type = responseImageType(resp.headers.get('X-media-bytes')); + if(type === ImageType.UNKNOWN) { + throw "unknown image type"; + } + + const media = imageType2MediaType(type); + await localIconCache.putCache(iconUniqueId, response.getResponse(), "image/" + media); + } catch (error) { + logWarn(LogCategory.FILE_TRANSFER, tr("Failed to save icon %s (%s) into local icon cache: %o"), icon.iconId, icon.serverUniqueId, error); + } + } +} + +loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + name: "icon init", + priority: 60, + function: async () => { + localIconCache = new ImageCache("icons"); + await localIconCache.setup(); + + setIconManager(new IconManager()); + } +}); + +//TODO! +/* +window.addEventListener("beforeunload", () => { + icon_cache_loader.clear_memory_cache(); +}); + +(window as any).flush_icon_cache = async () => { + icon_cache_loader.clear_memory_cache(); + await icon_cache_loader.clear_cache(); + + server_connections.all_connections().forEach(e => { + e.fileManager.icons.flush_cache(); + }); +}; + */ \ No newline at end of file