From 8aae8f3b78fdc5cf3287f2962390ab1368153c77 Mon Sep 17 00:00:00 2001 From: Teacup Date: Sat, 11 Feb 2023 16:21:38 -0500 Subject: [PATCH 1/6] fix(.NET): resolve cefsharp not loading uris with special characters Before this change, the browser would fail to navigate to the local index.html page if the path leading to the file contained any special characters. This resulted in the browser loading into a file view. This fix adds a custom URI scheme that retains the same base directory that was in use previously and sets the default file to index.html. This resolves the issue with cefsharp not loading uris with special characters. --- CefService.cs | 15 ++++++++++----- MainForm.cs | 4 +--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/CefService.cs b/CefService.cs index 93a9786f..74feb5b3 100644 --- a/CefService.cs +++ b/CefService.cs @@ -1,4 +1,5 @@ using CefSharp; +using CefSharp.SchemeHandler; using CefSharp.WinForms; using System; using System.IO; @@ -26,12 +27,16 @@ namespace VRCX PersistUserPreferences = true }; - /*cefSettings.RegisterScheme(new CefCustomScheme + cefSettings.RegisterScheme(new CefCustomScheme { - SchemeName = "vrcx", - DomainName = "app", - SchemeHandlerFactory = new FolderSchemeHandlerFactory(Application.StartupPath + "/../../../html") - });*/ + SchemeName = "localnjs", + DomainName = "vrcx", + SchemeHandlerFactory = new FolderSchemeHandlerFactory( + rootFolder: Path.Combine(Program.BaseDirectory, "html"), + hostName: "vrcx", + defaultPage: "index.html" + ) + }); // cefSettings.CefCommandLineArgs.Add("allow-universal-access-from-files"); // cefSettings.CefCommandLineArgs.Add("ignore-certificate-errors"); diff --git a/MainForm.cs b/MainForm.cs index 1694a6e6..40ec4442 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -39,9 +39,7 @@ namespace VRCX { } - Browser = new ChromiumWebBrowser( - Path.Combine(Program.BaseDirectory, "html/index.html") - ) + Browser = new ChromiumWebBrowser("localnjs://vrcx/") { DragHandler = new NoopDragHandler(), MenuHandler = new CustomMenuHandler(), From 0f3b8c732a0c13890700a9a65388e5f090fef9b7 Mon Sep 17 00:00:00 2001 From: Teacup Date: Sat, 11 Feb 2023 18:30:53 -0500 Subject: [PATCH 2/6] feat: Add options to save the current world name/id to screenshot metadata/filenames --- AppApi.cs | 22 ++++ LogWatcher.cs | 21 +++- ScreenshotHelper.cs | 152 +++++++++++++++++++++++ VRCX.csproj | 1 + html/src/app.js | 31 +++++ html/src/index.pug | 14 +++ html/src/localization/strings/en.json | 8 ++ html/src/localization/strings/ko.json | 8 ++ html/src/localization/strings/zh_TW.json | 10 +- html/src/service/gamelog.js | 4 + 10 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 ScreenshotHelper.cs diff --git a/AppApi.cs b/AppApi.cs index 7820a04f..86e648e0 100644 --- a/AppApi.cs +++ b/AppApi.cs @@ -634,5 +634,27 @@ namespace VRCX { } } + + public void AddScreenshotMetadata(string path, string worldName, string worldId, bool changeFilename = false) + { + try + { + string fileName = Path.GetFileNameWithoutExtension(path); + if (!File.Exists(path) || !path.EndsWith(".png") || !fileName.StartsWith("VRChat_")) return; + + if (changeFilename) + { + var newFileName = $"{fileName}_{worldId}"; + var newPath = Path.Combine(Path.GetDirectoryName(path), newFileName + Path.GetExtension(path)); + File.Move(path, newPath); + path = newPath; + } + + string metadataString = $"{Program.Version}||{worldId}||{worldName}"; + + ScreenshotHelper.WritePNGDescription(path, metadataString); + } + catch { } + } } } \ No newline at end of file diff --git a/LogWatcher.cs b/LogWatcher.cs index daca175a..f54acc07 100644 --- a/LogWatcher.cs +++ b/LogWatcher.cs @@ -168,7 +168,7 @@ namespace VRCX m_FirstRun = false; } - + private void ParseLog(FileInfo fileInfo, LogContext logContext) { try @@ -227,7 +227,8 @@ namespace VRCX ParseLogUsharpVideoPlay(fileInfo, logContext, line, offset) == true || ParseLogUsharpVideoSync(fileInfo, logContext, line, offset) == true || ParseLogWorldVRCX(fileInfo, logContext, line, offset) == true || - ParseLogOnAudioConfigurationChanged(fileInfo, logContext, line, offset) == true) + ParseLogOnAudioConfigurationChanged(fileInfo, logContext, line, offset) == true || + ParseLogScreenshot(fileInfo, logContext, line, offset) == true) { continue; } @@ -344,6 +345,22 @@ namespace VRCX return false; } + private bool ParseLogScreenshot(FileInfo fileInfo, LogContext logContext, string line, int offset) + { + // This won't work with VRChat's new "Multi Layer" camera mode, since it doesn't output any logs like normal pictures. + // 2023.02.08 12:31:35 Log - [VRC Camera] Took screenshot to: C:\Users\Tea\Pictures\VRChat\2023-02\VRChat_2023-02-08_12-31-35.104_1920x1080.png + if (!line.Contains("[VRC Camera] Took screenshot to: ")) + return false; + + var lineOffset = line.LastIndexOf("] Took screenshot to: "); + if (lineOffset < 0) + return true; + + var screenshotPath = line.Substring(lineOffset + 22); + AppendLog(new[] { fileInfo.Name, ConvertLogTimeToISO8601(line), "screenshot", screenshotPath }); + return true; + } + private bool ParseLogLocationDestination(FileInfo fileInfo, LogContext logContext, string line, int offset) { // 2021.09.02 00:02:12 Log - [Behaviour] Destination set: wrld_4432ea9b-729c-46e3-8eaf-846aa0a37fdd:15609~private(usr_032383a7-748c-4fb2-94e4-bcb928e5de6b)~nonce(72CC87D420C1D49AEFFBEE8824C84B2DF0E38678E840661E) diff --git a/ScreenshotHelper.cs b/ScreenshotHelper.cs new file mode 100644 index 00000000..23f15943 --- /dev/null +++ b/ScreenshotHelper.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace VRCX +{ + internal static class ScreenshotHelper + { + private static byte[] pngSignatureBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + + public static bool WritePNGDescription(string path, string text) + { + if (!File.Exists(path) || !IsPNGFile(path)) return false; + + var png = File.ReadAllBytes(path); + + int newChunkIndex = FindChunk(png, "IHDR"); + if (newChunkIndex == -1) return false; + + // If this file already has a text chunk, chances are it got logged twice for some reason. Stop. + int existingiTXt = FindChunk(png, "iTXt"); + if (existingiTXt != -1) return false; + + var newChunk = new PNGChunk("iTXt"); + newChunk.InitializeTextChunk("Description", text); + + var newFile = png.ToList(); + newFile.InsertRange(newChunkIndex, newChunk.ConstructChunkByteArray()); + + File.WriteAllBytes(path, newFile.ToArray()); + + return true; + } + + public static bool IsPNGFile(string path) + { + var png = File.ReadAllBytes(path); + var pngSignature = png.Take(8).ToArray(); + return pngSignatureBytes.SequenceEqual(pngSignature); + } + + static int FindChunk(byte[] png, string type) + { + int index = 8; + + while (index < png.Length) + { + byte[] chunkLength = new byte[4]; + Array.Copy(png, index, chunkLength, 0, 4); + Array.Reverse(chunkLength); + int length = BitConverter.ToInt32(chunkLength, 0); + + byte[] chunkName = new byte[4]; + Array.Copy(png, index + 4, chunkName, 0, 4); + string name = Encoding.ASCII.GetString(chunkName); + + if (name == type) + { + return index + length + 12; + } + index += length + 12; + } + + return -1; + } + } + + // See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html 4.2.3 + // Basic PNG Chunk Structure: Length(int, 4 bytes) | Type (string, 4 bytes) | chunk data (Depends on type) | 32-bit CRC code (4 bytes) + // basic tEXt data structure: Keyword (1-79 bytes string) | Null separator (1 byte) | Text (x bytes) + // basic iTXt data structure: Keyword (1-79 bytes string) | Null separator (1 byte) | Compression flag (1 byte) | Compression method (1 byte) | Language tag (0-x bytes) | Null separator | Translated keyword (0-x bytes) | Null separator | Text (x bytes) + + // Proper practice here for arbitrary image processing would be to check the PNG file being passed for any existing iTXt chunks with the same keyword that we're trying to use; If we find one, we replace that chunk's data instead of creating a new chunk. + // Luckily, VRChat should never do this! Bugs notwithstanding, we should never re-process a png file either. So we're just going to skip that logic. + internal class PNGChunk + { + public string ChunkType; + public List ChunkDataBytes; + public int ChunkDataLength; + + // crc lookup table + private static uint[] crcTable; + // init lookup table and store crc for iTXt + private static uint tEXtCRC = Crc32(new byte[] { (byte)'i', (byte)'T', (byte)'X', (byte)'t' }, 0, 4, 0); + + public PNGChunk(string chunkType) + { + this.ChunkType = chunkType; + this.ChunkDataBytes = new List(); + } + + // Construct iTXt chunk data + public void InitializeTextChunk(string keyword, string text) + { + // Create our chunk data byte array + ChunkDataBytes.AddRange(Encoding.UTF8.GetBytes(keyword)); // keyword + ChunkDataBytes.Add(0x0); // Null separator + ChunkDataBytes.Add(0x0); // Compression flag + ChunkDataBytes.Add(0x0); // Compression method + ChunkDataBytes.Add(0x0); // Null separator (skipping over language tag byte) + ChunkDataBytes.Add(0x0); // Null separator (skipping over translated keyword byte) + ChunkDataBytes.AddRange(Encoding.UTF8.GetBytes(text)); // our text + + ChunkDataLength = ChunkDataBytes.Count; + } + + // Construct & return PNG chunk + public byte[] ConstructChunkByteArray() + { + List chunk = new List(); + + chunk.AddRange(BitConverter.GetBytes(ChunkDataLength).Reverse()); // add data length + chunk.AddRange(Encoding.ASCII.GetBytes(ChunkType)); // add chunk type + chunk.AddRange(ChunkDataBytes); // Add chunk data + chunk.AddRange(BitConverter.GetBytes(Crc32(ChunkDataBytes.ToArray(), 0, ChunkDataLength, tEXtCRC)).Reverse()); // Add chunk CRC32 hash + + return chunk.ToArray(); + } + + // https://web.archive.org/web/20150825201508/http://upokecenter.dreamhosters.com/articles/png-image-encoder-in-c/ + private static uint Crc32(byte[] stream, int offset, int length, uint crc) + { + uint c; + if (crcTable == null) + { + crcTable = new uint[256]; + for (uint n = 0; n <= 255; n++) + { + c = n; + for (var k = 0; k <= 7; k++) + { + if ((c & 1) == 1) + c = 0xEDB88320 ^ ((c >> 1) & 0x7FFFFFFF); + else + c = ((c >> 1) & 0x7FFFFFFF); + } + crcTable[n] = c; + } + } + c = crc ^ 0xffffffff; + var endOffset = offset + length; + for (var i = offset; i < endOffset; i++) + { + c = crcTable[(c ^ stream[i]) & 255] ^ ((c >> 8) & 0xFFFFFF); + } + return c ^ 0xffffffff; + } + } +} diff --git a/VRCX.csproj b/VRCX.csproj index 71d4f54e..eb825f71 100644 --- a/VRCX.csproj +++ b/VRCX.csproj @@ -89,6 +89,7 @@ + diff --git a/html/src/app.js b/html/src/app.js index d46903ec..66f33fda 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -9272,6 +9272,22 @@ speechSynthesis.getVoices(); this.nowPlaying.offset = parseInt(timestamp, 10); } return; + case 'screenshot': + if (!this.isGameRunning || !this.screenshotHelper) return; + + var entry = { + created_at: gameLog.dt, + type: 'Event', + //location: location, + data: "Screenshot Processed: " + gameLog.screenshotPath.replace(/^.*[\\\/]/, ''), + }; + + let world = API.parseLocation(this.lastLocation.location); + let worldID = world.worldId; + + database.addGamelogEventToDatabase(entry); + AppApi.AddScreenshotMetadata(gameLog.screenshotPath, this.lastLocation.name, worldID, this.screenshotHelperModifyFilename); + break; case 'api-request': var bias = Date.parse(gameLog.dt) + 60 * 1000; if ( @@ -13477,6 +13493,9 @@ speechSynthesis.getVoices(); 'VRCX_progressPieFilter' ); + $app.data.screenshotHelper = configRepository.getBool('VRCX_screenshotHelper'); + $app.data.screenshotHelperModifyFilename = configRepository.getBool('VRCX_screenshotHelperModifyFilename'); + $app.methods.updateVRConfigVars = function () { var notificationTheme = 'relax'; if (this.isDarkMode) { @@ -20060,6 +20079,18 @@ speechSynthesis.getVoices(); this.VRChatConfigFile.screenshot_res_width = res.width; }; + // Screenshot Helper + + $app.methods.saveScreenshotHelper = function () { + console.log("save helper toggle press") + configRepository.setBool('VRCX_screenshotHelper', this.screenshotHelper); + }; + + $app.methods.saveScreenshotHelperModifyFilename = function () { + console.log("save helper filename toggle press") + configRepository.setBool('VRCX_screenshotHelperSaveFilename', this.screenshotHelperModifyFilename); + }; + // YouTube API $app.data.youTubeApiKey = ''; diff --git a/html/src/index.pug b/html/src/index.pug index 35ae4c86..1e9dc39c 100644 --- a/html/src/index.pug +++ b/html/src/index.pug @@ -1444,6 +1444,20 @@ html div.options-container-item span.name {{ $t('view.settings.advanced.advanced.video_progress_pie.dance_world_only') }} el-switch(v-model="progressPieFilter" @change="changeYouTubeApi" :disabled="!openVR") + div.options-container + span.header {{ $t('view.settings.advanced.advanced.screenshot_helper.header') }} + div.options-container-item + span.name {{ $t('view.settings.advanced.advanced.screenshot_helper.description') }} + el-tooltip(placement="top" style="margin-left:5px" :content="$t('view.settings.advanced.advanced.screenshot_helper.description_tooltip')") + i.el-icon-info + div.options-container-item + span.name {{ $t('view.settings.advanced.advanced.screenshot_helper.enable') }} + el-switch(v-model="screenshotHelper" @change="saveScreenshotHelper") + div.options-container-item + span.name {{ $t('view.settings.advanced.advanced.screenshot_helper.modify_filename') }} + el-tooltip(placement="top" style="margin-left:5px" :content="$t('view.settings.advanced.advanced.screenshot_helper.modify_filename_tooltip')") + i.el-icon-info + el-switch(v-model="screenshotHelperModifyFilename" @change="saveScreenshotHelperModifyFilename") div.options-container(v-if="photonLoggingEnabled") span.header {{ $t('view.settings.advanced.photon.header') }} div.options-container-item diff --git a/html/src/localization/strings/en.json b/html/src/localization/strings/en.json index b1dd3467..a71460b4 100644 --- a/html/src/localization/strings/en.json +++ b/html/src/localization/strings/en.json @@ -381,6 +381,14 @@ "enable_tooltip": "Requires SteamVR overlay to be enabled", "dance_world_only": "Dance worlds only" }, + "screenshot_helper": { + "header": "Screenshot Helper", + "description": "Will store the world ID and world name in the file metadata of any pictures you take in-game.", + "description_tooltip": "Unfortunately, windows doesn't support viewing PNG text chunks(few things do) natively, but you can view it using a command-line tool like exiftool, a png chunk inspector, or a hex editor.", + "enable": "Enable", + "modify_filename": "Modify Filename", + "modify_filename_tooltip": "Will add the World ID to screenshot filename, in addition to file metadata." + }, "cache_debug": { "header": "VRCX Instance Cache/Debug", "disable_gamelog": "Disable GameLog", diff --git a/html/src/localization/strings/ko.json b/html/src/localization/strings/ko.json index 0b0106e4..49484c6e 100644 --- a/html/src/localization/strings/ko.json +++ b/html/src/localization/strings/ko.json @@ -380,6 +380,14 @@ "enable_tooltip": "스팀VR 오버레이 사용 필요", "dance_world_only": "댄스 월드에서만 사용" }, + "screenshot_helper": { + "header": "스크린샷 도우미", + "description": "게임 내에서 찍은 사진의 파일 메타데이터에 월드 ID와 월드 이름을 저장합니다.", + "description_tooltip": "안타깝게도 Windows는 기본적으로 PNG 텍스트 청크 보기를 지원하지 않지만(몇 가지 기능은 지원), exiftool, PNG chunk inspector 또는 헥스 에디터와 같은 명령줄 도구를 사용하여 볼 수 있습니다.", + "enable": "사용", + "modify_filename": "파일 이름 수정", + "modify_filename_tooltip": "파일 메타데이터 외에 스크린샷 파일 이름에 월드 ID를 추가합니다." + }, "cache_debug": { "header": "VRCX 인스턴스 캐시/디버그", "disable_gamelog": "게임 기록 하지 않기", diff --git a/html/src/localization/strings/zh_TW.json b/html/src/localization/strings/zh_TW.json index 72e8e4b7..04450653 100644 --- a/html/src/localization/strings/zh_TW.json +++ b/html/src/localization/strings/zh_TW.json @@ -374,11 +374,13 @@ "enable": "啟用", "youtube_api_key": "Youtube API 金鑰" }, - "video_progress_pie": { - "header": "影片進度圓餅疊層", + "screenshot_helper": { + "header": "屏幕截图助手", + "description": "将在你在游戏中拍摄的任何照片的文件元数据中存储世界ID和世界名称。", + "description_tooltip": "不幸的是,windows不支持本机查看PNG文本块(很多东西都不支持),但你可以使用exiftool等命令行工具、png块检查器或十六进制编辑器来查看。", "enable": "啟用", - "enable_tooltip": "需要啟用 SteamVR 疊層選項", - "dance_world_only": "僅限跳舞世界" + "modify_filename": "修改文件名", + "modify_filename_tooltip": "除了文件元数据外,将把世界ID添加到截图文件名中。" }, "cache_debug": { "header": "VRCX 世界快取/除錯", diff --git a/html/src/service/gamelog.js b/html/src/service/gamelog.js index db0cabd8..ee4b3d31 100644 --- a/html/src/service/gamelog.js +++ b/html/src/service/gamelog.js @@ -58,6 +58,10 @@ class GameLogService { gameLog.photonId = args[1]; break; + case 'screenshot': + gameLog.screenshotPath = args[0] + break; + case 'vrc-quit': break; From 35b2a4a5a22dc694dd3b4955b9a37501400920b2 Mon Sep 17 00:00:00 2001 From: Teacup Date: Sat, 11 Feb 2023 18:42:34 -0500 Subject: [PATCH 3/6] refactor: Remove screenshot helper option debug logging --- html/src/app.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/html/src/app.js b/html/src/app.js index 66f33fda..20dd305c 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -20082,12 +20082,10 @@ speechSynthesis.getVoices(); // Screenshot Helper $app.methods.saveScreenshotHelper = function () { - console.log("save helper toggle press") configRepository.setBool('VRCX_screenshotHelper', this.screenshotHelper); }; $app.methods.saveScreenshotHelperModifyFilename = function () { - console.log("save helper filename toggle press") configRepository.setBool('VRCX_screenshotHelperSaveFilename', this.screenshotHelperModifyFilename); }; From e0becbfa626647b1b8b0985bb1989b47d921a89e Mon Sep 17 00:00:00 2001 From: Teacup Date: Sat, 11 Feb 2023 18:50:19 -0500 Subject: [PATCH 4/6] fix: screenshot helper toggle typo --- html/src/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/html/src/app.js b/html/src/app.js index 20dd305c..31812221 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -20086,7 +20086,7 @@ speechSynthesis.getVoices(); }; $app.methods.saveScreenshotHelperModifyFilename = function () { - configRepository.setBool('VRCX_screenshotHelperSaveFilename', this.screenshotHelperModifyFilename); + configRepository.setBool('VRCX_screenshotHelperModifyFilename', this.screenshotHelperModifyFilename); }; // YouTube API From dee66acef3944e9cad28545893d1c7eef5596ada Mon Sep 17 00:00:00 2001 From: Teacup Date: Sat, 11 Feb 2023 19:30:30 -0500 Subject: [PATCH 5/6] Restore a chinese translation tbl that got replaced by accident. Whoops. --- html/src/localization/strings/zh_TW.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/html/src/localization/strings/zh_TW.json b/html/src/localization/strings/zh_TW.json index 04450653..d5d4582d 100644 --- a/html/src/localization/strings/zh_TW.json +++ b/html/src/localization/strings/zh_TW.json @@ -374,6 +374,12 @@ "enable": "啟用", "youtube_api_key": "Youtube API 金鑰" }, + "video_progress_pie": { + "header": "影片進度圓餅疊層", + "enable": "啟用", + "enable_tooltip": "需要啟用 SteamVR 疊層選項", + "dance_world_only": "僅限跳舞世界" + }, "screenshot_helper": { "header": "屏幕截图助手", "description": "将在你在游戏中拍摄的任何照片的文件元数据中存储世界ID和世界名称。", From 3931613a90e7b1677dacdf6530177e3671f9f0cc Mon Sep 17 00:00:00 2001 From: Teacup Date: Mon, 13 Feb 2023 15:20:36 -0500 Subject: [PATCH 6/6] fix: fix custom css/js not being loaded For some reason our new URI scheme prevented the file:// scheme from being used script-side. This fixes that. Thanks Natsumi --- CefService.cs | 9 +++++---- MainForm.cs | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CefService.cs b/CefService.cs index 74feb5b3..3d8824e1 100644 --- a/CefService.cs +++ b/CefService.cs @@ -26,16 +26,17 @@ namespace VRCX PersistSessionCookies = true, PersistUserPreferences = true }; - + cefSettings.RegisterScheme(new CefCustomScheme { - SchemeName = "localnjs", + SchemeName = "file", DomainName = "vrcx", SchemeHandlerFactory = new FolderSchemeHandlerFactory( rootFolder: Path.Combine(Program.BaseDirectory, "html"), - hostName: "vrcx", + schemeName: "file", defaultPage: "index.html" - ) + ), + IsLocal = true }); // cefSettings.CefCommandLineArgs.Add("allow-universal-access-from-files"); diff --git a/MainForm.cs b/MainForm.cs index 40ec4442..b973032c 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -39,7 +39,7 @@ namespace VRCX { } - Browser = new ChromiumWebBrowser("localnjs://vrcx/") + Browser = new ChromiumWebBrowser("file://vrcx/index.html") { DragHandler = new NoopDragHandler(), MenuHandler = new CustomMenuHandler(),