Updated the icon API

This commit is contained in:
WolverinDEV 2020-09-26 01:22:05 +02:00 committed by WolverinDEV
parent 73f523ee52
commit 8f477437af
6 changed files with 490 additions and 432 deletions

View file

@ -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<FileManagerEvents>;
readonly finishedTransfers: FinishedFileTransfer[] = [];
@ -409,7 +407,6 @@ export class FileManager {
this.commandHandler = new FileCommandHandler(this);
this.events = new Registry<FileManagerEvents>();
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<void> {
if(iconId <= 1000) {
throw "invalid id!";
}
await this.deleteFile({
name: '/icon_' + (iconId >>> 0)
});
}
registeredTransfers() : FileTransfer[] {
return this.registeredTransfers_.map(e => e.transfer);
}

189
shared/js/file/Icons.ts Normal file
View file

@ -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<RemoteIconEvents>;
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<RemoteIconEvents>();
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<HTMLDivElement> {
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;
}

View file

@ -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<Response>;
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<IconManagerEvents>;
private loading_timestamps: {[key: number]: IconManagerLoadingData} = {};
constructor(handle: FileManager) {
this.handle = handle;
this.events = new Registry<IconManagerEvents>();
}
destroy() {
this.loading_timestamps = {};
}
async delete_icon(id: number) : Promise<void> {
if(id <= 1000)
throw "invalid id!";
await this.handle.deleteFile({
name: '/icon_' + id
});
}
iconList() : Promise<FileInfo[]> {
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<Response> {
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<HTMLDivElement> {
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<HTMLDivElement> {
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 = {};
}
}

View file

@ -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<Response | undefined> {
async resolveCached(key: string, max_age?: number) : Promise<Response | undefined> {
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));

View file

@ -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;

View file

@ -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<string> {
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();
});
};
*/