From 6f85c76607fe47150a788733bb53e629f1f7615a Mon Sep 17 00:00:00 2001 From: Natsumi Date: Sun, 17 Nov 2024 10:11:52 +1300 Subject: [PATCH] Save Instance Prints To File --- Dotnet/AppApi/AppApi.cs | 12 +- Dotnet/AppApi/Screenshot.cs | 2 + Dotnet/ImageCache.cs | 160 +++++++++++------- html/src/app.js | 56 +++++- html/src/classes/gameLog.js | 21 ++- html/src/classes/uiComponents.js | 3 + html/src/classes/websocket.js | 25 ++- html/src/localization/en/en.json | 4 + .../src/mixins/dialogs/screenshotMetadata.pug | 2 +- html/src/mixins/tabs/settings.pug | 4 + 10 files changed, 215 insertions(+), 74 deletions(-) diff --git a/Dotnet/AppApi/AppApi.cs b/Dotnet/AppApi/AppApi.cs index dae37662e..06e21df59 100644 --- a/Dotnet/AppApi/AppApi.cs +++ b/Dotnet/AppApi/AppApi.cs @@ -30,7 +30,6 @@ public partial class AppApi private static readonly Logger logger = LogManager.GetCurrentClassLogger(); private static readonly MD5 _hasher = MD5.Create(); - private static bool dialogOpen; static AppApi() { @@ -575,5 +574,16 @@ public string GetFileBase64(string path) return null; } + + public async Task SavePrintToFile(string url, string fileName) + { + var directory = Path.Combine(GetVRChatPhotosLocation(), "Prints"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, fileName); + if (File.Exists(path)) + return false; + + return await ImageCache.SaveImageToFile(url, path); + } } } \ No newline at end of file diff --git a/Dotnet/AppApi/Screenshot.cs b/Dotnet/AppApi/Screenshot.cs index bb1978fff..2bb48447f 100644 --- a/Dotnet/AppApi/Screenshot.cs +++ b/Dotnet/AppApi/Screenshot.cs @@ -12,6 +12,8 @@ namespace VRCX { public partial class AppApi { + private static bool dialogOpen; + /// /// Adds metadata to a PNG screenshot file and optionally renames the file to include the specified world ID. /// diff --git a/Dotnet/ImageCache.cs b/Dotnet/ImageCache.cs index fe887f6eb..f98a4c9e5 100644 --- a/Dotnet/ImageCache.cs +++ b/Dotnet/ImageCache.cs @@ -6,89 +6,119 @@ using System.Net.Http; using System.Threading.Tasks; -namespace VRCX +namespace VRCX; + +internal static class ImageCache { - internal static class ImageCache - { - private static readonly string cacheLocation; - private static readonly HttpClient httpClient; - private static readonly List _imageHosts = - [ - "api.vrchat.cloud", - "files.vrchat.cloud", - "d348imysud55la.cloudfront.net", - "assets.vrchat.com" - ]; + private static readonly string cacheLocation; + private static readonly HttpClient httpClient; + private static readonly List _imageHosts = + [ + "api.vrchat.cloud", + "files.vrchat.cloud", + "d348imysud55la.cloudfront.net", + "assets.vrchat.com" + ]; - static ImageCache() - { - cacheLocation = Path.Combine(Program.AppDataDirectory, "ImageCache"); - var httpClientHandler = new HttpClientHandler(); - if (WebApi.ProxySet) - httpClientHandler.Proxy = WebApi.Proxy; + static ImageCache() + { + cacheLocation = Path.Combine(Program.AppDataDirectory, "ImageCache"); + var httpClientHandler = new HttpClientHandler(); + if (WebApi.ProxySet) + httpClientHandler.Proxy = WebApi.Proxy; - httpClient = new HttpClient(httpClientHandler); - } + httpClient = new HttpClient(httpClientHandler); + } - public static async Task GetImage(string url, string fileId, string version) - { - var directoryLocation = Path.Combine(cacheLocation, fileId); - var fileLocation = Path.Combine(directoryLocation, $"{version}.png"); + public static async Task GetImage(string url, string fileId, string version) + { + var directoryLocation = Path.Combine(cacheLocation, fileId); + var fileLocation = Path.Combine(directoryLocation, $"{version}.png"); - if (File.Exists(fileLocation)) - { - Directory.SetLastWriteTimeUtc(directoryLocation, DateTime.UtcNow); - return fileLocation; - } + if (File.Exists(fileLocation)) + { + Directory.SetLastWriteTimeUtc(directoryLocation, DateTime.UtcNow); + return fileLocation; + } - if (Directory.Exists(directoryLocation)) - Directory.Delete(directoryLocation, true); - Directory.CreateDirectory(directoryLocation); + if (Directory.Exists(directoryLocation)) + Directory.Delete(directoryLocation, true); + Directory.CreateDirectory(directoryLocation); - var uri = new Uri(url); - if (!_imageHosts.Contains(uri.Host)) - throw new ArgumentException("Invalid image host", url); + var uri = new Uri(url); + if (!_imageHosts.Contains(uri.Host)) + throw new ArgumentException("Invalid image host", url); - var cookieString = string.Empty; - if (WebApi.Instance != null && WebApi.Instance._cookieContainer != null) - { - CookieCollection cookies = WebApi.Instance._cookieContainer.GetCookies(new Uri("https://api.vrchat.cloud")); - foreach (Cookie cookie in cookies) - cookieString += $"{cookie.Name}={cookie.Value};"; - } + var cookieString = string.Empty; + if (WebApi.Instance != null && WebApi.Instance._cookieContainer != null) + { + CookieCollection cookies = WebApi.Instance._cookieContainer.GetCookies(new Uri("https://api.vrchat.cloud")); + foreach (Cookie cookie in cookies) + cookieString += $"{cookie.Name}={cookie.Value};"; + } - var request = new HttpRequestMessage(HttpMethod.Get, url) + var request = new HttpRequestMessage(HttpMethod.Get, url) + { + Headers = { - Headers = - { - { "Cookie", cookieString }, - { "User-Agent", Program.Version } - } - }; - using (var response = await httpClient.SendAsync(request)) + { "Cookie", cookieString }, + { "User-Agent", Program.Version } + } + }; + using (var response = await httpClient.SendAsync(request)) + { + response.EnsureSuccessStatusCode(); + await using (var fileStream = new FileStream(fileLocation, FileMode.Create, FileAccess.Write, FileShare.None)) { - response.EnsureSuccessStatusCode(); - await using (var fileStream = new FileStream(fileLocation, FileMode.Create, FileAccess.Write, FileShare.None)) - { - await response.Content.CopyToAsync(fileStream); - } + await response.Content.CopyToAsync(fileStream); } + } - var cacheSize = Directory.GetDirectories(cacheLocation).Length; - if (cacheSize > 1100) - CleanImageCache(); + var cacheSize = Directory.GetDirectories(cacheLocation).Length; + if (cacheSize > 1100) + CleanImageCache(); - return fileLocation; + return fileLocation; + } + + private static void CleanImageCache() + { + var dirInfo = new DirectoryInfo(cacheLocation); + var folders = dirInfo.GetDirectories().OrderByDescending(p => p.LastWriteTime).Skip(1000); + foreach (var folder in folders) + { + folder.Delete(true); } + } - private static void CleanImageCache() + public static async Task SaveImageToFile(string url, string path) + { + var uri = new Uri(url); + if (!_imageHosts.Contains(uri.Host)) + throw new ArgumentException("Invalid image host", url); + + var cookieString = string.Empty; + if (WebApi.Instance != null && WebApi.Instance._cookieContainer != null) + { + var cookies = WebApi.Instance._cookieContainer.GetCookies(new Uri("https://api.vrchat.cloud")); + foreach (Cookie cookie in cookies) + cookieString += $"{cookie.Name}={cookie.Value};"; + } + + var request = new HttpRequestMessage(HttpMethod.Get, url) { - var dirInfo = new DirectoryInfo(cacheLocation); - var folders = dirInfo.GetDirectories().OrderByDescending(p => p.LastWriteTime).Skip(1000); - foreach (var folder in folders) + Headers = { - folder.Delete(true); + { "Cookie", cookieString }, + { "User-Agent", Program.Version } } - } + }; + using var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + return false; + + await using var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + await response.Content.CopyToAsync(fileStream); + return true; } } \ No newline at end of file diff --git a/html/src/app.js b/html/src/app.js index 6f1a3c54a..c85f3c856 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -5458,7 +5458,11 @@ speechSynthesis.getVoices(); } } this.lastLocation.playerList.forEach((ref1) => { - if (ref1.userId && !API.cachedUsers.has(ref1.userId)) { + if ( + ref1.userId && + typeof ref1.userId === 'string' && + !API.cachedUsers.has(ref1.userId) + ) { API.getUser({ userId: ref1.userId }); } }); @@ -8174,6 +8178,10 @@ speechSynthesis.getVoices(); 'VRCX_StartAtWindowsStartup', this.isStartAtWindowsStartup ); + await configRepository.setBool( + 'VRCX_saveInstancePrints', + this.saveInstancePrints + ); VRCXStorage.Set( 'VRCX_StartAsMinimizedState', this.isStartAsMinimizedState.toString() @@ -17547,6 +17555,52 @@ speechSynthesis.getVoices(); } }); + API.getPrint = function (params) { + return this.call(`prints/${params.printId}`, { + method: 'GET' + }).then((json) => { + var args = { + json, + params + }; + this.$emit('PRINT', args); + return args; + }); + }; + + API.editPrint = function (params) { + return this.call(`prints/${params.printId}`, { + method: 'POST', + params + }).then((json) => { + var args = { + json, + params + }; + this.$emit('PRINT:EDIT', args); + return args; + }); + }; + + $app.data.saveInstancePrints = await configRepository.getBool( + 'VRCX_saveInstancePrints', + false + ); + + $app.methods.trySavePrintToFile = async function (printId) { + var print = await API.getPrint({ printId }); + var imageUrl = print.json?.files?.image; + if (!imageUrl) { + console.error('Print image URL is missing', print); + return; + } + var fileName = `${printId}.png`; + var status = await AppApi.SavePrintToFile(imageUrl, fileName); + if (status) { + console.log(`Print saved to file: ${fileName}`); + } + }; + // #endregion // #region | Emoji diff --git a/html/src/classes/gameLog.js b/html/src/classes/gameLog.js index 9923c20f0..509a07ca1 100644 --- a/html/src/classes/gameLog.js +++ b/html/src/classes/gameLog.js @@ -39,7 +39,7 @@ export default class extends baseClass { if (this.gameLogDisabled) { return; } - var userId = gameLog.userId; + var userId = String(gameLog.userId || ''); if (!userId && gameLog.displayName) { for (var ref of API.cachedUsers.values()) { if (ref.displayName === gameLog.displayName) { @@ -257,6 +257,25 @@ export default class extends baseClass { // if (!userId) { // break; // } + + if (!$app.saveInstancePrints) { + break; + } + try { + var printId = ''; + var url = new URL(gameLog.url); + if ( + url.pathname.substring(0, 14) === '/api/1/prints/' + ) { + var pathArray = url.pathname.split('/'); + printId = pathArray[4]; + } + if (printId && printId.length === 41) { + $app.trySavePrintToFile(printId); + } + } catch (err) { + console.error(err); + } break; case 'avatar-change': var ref = this.lastLocation.playerList.get(userId); diff --git a/html/src/classes/uiComponents.js b/html/src/classes/uiComponents.js index 336e00e7c..c6a6d6ef2 100644 --- a/html/src/classes/uiComponents.js +++ b/html/src/classes/uiComponents.js @@ -598,6 +598,9 @@ export default class extends baseClass { }, key() { this.parse(); + }, + userid() { + this.parse(); } }, mounted() { diff --git a/html/src/classes/websocket.js b/html/src/classes/websocket.js index d2e2d4609..c20651b64 100644 --- a/html/src/classes/websocket.js +++ b/html/src/classes/websocket.js @@ -514,19 +514,34 @@ export default class extends baseClass { var contentType = content.contentType; console.log('content-refresh', content); if (contentType === 'icon') { - if ($app.galleryDialogVisible) { + if ( + $app.galleryDialogVisible && + !$app.galleryDialogIconsLoading + ) { $app.refreshVRCPlusIconsTable(); } } else if (contentType === 'gallery') { - if ($app.galleryDialogVisible) { + if ( + $app.galleryDialogVisible && + !$app.galleryDialogGalleryLoading + ) { $app.refreshGalleryTable(); } } else if (contentType === 'emoji') { - if ($app.galleryDialogVisible) { + if ( + $app.galleryDialogVisible && + !$app.galleryDialogEmojisLoading + ) { $app.refreshEmojiTable(); } - } else if (contentType === 'print') { - if ($app.galleryDialogVisible) { + } else if ( + contentType === 'print' || + contentType === 'prints' + ) { + if ( + $app.galleryDialogVisible && + !$app.galleryDialogPrintsLoading + ) { $app.refreshPrintTable(); } } else if (contentType === 'avatar') { diff --git a/html/src/localization/en/en.json b/html/src/localization/en/en.json index 87d6b21f9..9ede25948 100644 --- a/html/src/localization/en/en.json +++ b/html/src/localization/en/en.json @@ -433,6 +433,10 @@ "header": "Local World Persistence", "description": "Localhost webserver (requires restart)" }, + "save_instance_prints_to_file": { + "header": "Save Instance Prints To File", + "description": "Save spawned prints to your VRChat Pictures folder" + }, "remote_database": { "header": "Remote Avatar Database", "enable": "Enable", diff --git a/html/src/mixins/dialogs/screenshotMetadata.pug b/html/src/mixins/dialogs/screenshotMetadata.pug index 298c5985b..aebb8b784 100644 --- a/html/src/mixins/dialogs/screenshotMetadata.pug +++ b/html/src/mixins/dialogs/screenshotMetadata.pug @@ -32,7 +32,7 @@ mixin screenshotMetadata() br location(v-if="screenshotMetadataDialog.metadata.world" :location="screenshotMetadataDialog.metadata.world.instanceId" :hint="screenshotMetadataDialog.metadata.world.name") br - display-name(v-if="screenshotMetadataDialog.metadata.author" :userid="screenshotMetadataDialog.metadata.author.id" :hind="screenshotMetadataDialog.metadata.author.displayName" style="color:#909399;font-family:monospace") + display-name(v-if="screenshotMetadataDialog.metadata.author" :userid="screenshotMetadataDialog.metadata.author.id" :hint="screenshotMetadataDialog.metadata.author.displayName" style="color:#909399;font-family:monospace") br el-carousel(ref="screenshotMetadataCarousel" :interval="0" initial-index="1" indicator-position="none" arrow="always" height="600px" style="margin-top:10px" @change="screenshotMetadataCarouselChange") el-carousel-item diff --git a/html/src/mixins/tabs/settings.pug b/html/src/mixins/tabs/settings.pug index f80cfaca0..4fcc907b6 100644 --- a/html/src/mixins/tabs/settings.pug +++ b/html/src/mixins/tabs/settings.pug @@ -475,6 +475,10 @@ mixin settingsTab() div.options-container-item span.name(style="min-width:300px") {{ $t('view.settings.advanced.advanced.local_world_persistence.description') }} el-switch(v-model="disableWorldDatabase" :active-value="false" :inactive-value="true" @change="saveVRCXWindowOption") + span.sub-header {{ $t('view.settings.advanced.advanced.save_instance_prints_to_file.header') }} + div.options-container-item + span.name(style="min-width:300px") {{ $t('view.settings.advanced.advanced.save_instance_prints_to_file.description') }} + el-switch(v-model="saveInstancePrints" @change="saveVRCXWindowOption") //- Advanced | Remote Avatar Database div.options-container span.header {{ $t('view.settings.advanced.advanced.remote_database.header') }}