diff --git a/src/common/string_util.cpp b/src/common/string_util.cpp index 1637c01f1..117c19df2 100644 --- a/src/common/string_util.cpp +++ b/src/common/string_util.cpp @@ -1,3 +1,7 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + // Copyright 2013 Dolphin Emulator Project / 2014 Citra Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -20,6 +24,10 @@ #include #endif +#if defined(__APPLE__) +#include +#endif + namespace Common { /// Make a char lowercase @@ -164,6 +172,52 @@ std::u16string UTF8ToUTF16(std::string_view input) { return boost::locale::conv::utf_to_utf(input.data(), input.data() + input.size()); } +#if defined(__APPLE__) +// macOS filesystems may expose decomposed Unicode names through directory listings. +// Normalize to NFC before passing names to guest APIs that expect stable text. +std::string NormalizeNFDToNFC(std::string_view input) { + const std::string fallback(input); + + // Core Foundation string + CFStringRef source = + CFStringCreateWithBytes(kCFAllocatorDefault, reinterpret_cast(input.data()), + static_cast(input.size()), kCFStringEncodingUTF8, false); + + if (source == nullptr) { + return fallback; + } + + // Mutable copy of the source string + CFMutableStringRef normalized = CFStringCreateMutableCopy(kCFAllocatorDefault, 0, source); + CFRelease(source); + + if (normalized == nullptr) { + return fallback; + } + // Normalize the string to NFC form + CFStringNormalize(normalized, kCFStringNormalizationFormC); + + const CFIndex max_size = CFStringGetMaximumSizeForEncoding(CFStringGetLength(normalized), + kCFStringEncodingUTF8) + + 1; // +1 for null terminator + + std::string output(static_cast(max_size), '\0'); + + // Convert the normalized string back to UTF-8 + const bool converted = + CFStringGetCString(normalized, &output[0], max_size, kCFStringEncodingUTF8); + + CFRelease(normalized); + + if (!converted) { + return fallback; + } + + output.resize(std::strlen(output.c_str())); + return output; +} +#endif + #ifdef _WIN32 static std::wstring CPToUTF16(u32 code_page, const std::string& input) { const auto size = diff --git a/src/common/string_util.h b/src/common/string_util.h index 342434928..033c3ddb1 100644 --- a/src/common/string_util.h +++ b/src/common/string_util.h @@ -49,6 +49,10 @@ void BuildCompleteFilename(std::string& _CompleteFilename, const std::string& _P [[nodiscard]] std::string UTF16ToUTF8(std::u16string_view input); [[nodiscard]] std::u16string UTF8ToUTF16(std::string_view input); +// Returns UTF-8 normalized to NFC on platforms that need explicit Unicode normalization. +#if defined(__APPLE__) +[[nodiscard]] std::string NormalizeNFDToNFC(std::string_view input); +#endif #ifdef _WIN32 [[nodiscard]] std::string UTF16ToUTF8(const std::wstring& input); diff --git a/src/core/file_sys/disk_archive.cpp b/src/core/file_sys/disk_archive.cpp index a7ae5e92e..db0290df6 100644 --- a/src/core/file_sys/disk_archive.cpp +++ b/src/core/file_sys/disk_archive.cpp @@ -1,13 +1,15 @@ -// Copyright 2014 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. #include +#include #include #include "common/archives.h" #include "common/common_types.h" #include "common/file_util.h" #include "common/logging/log.h" +#include "common/string_util.h" #include "core/file_sys/disk_archive.h" #include "core/file_sys/errors.h" @@ -62,22 +64,29 @@ u32 DiskDirectory::Read(const u32 count, Entry* entries) { while (entries_read < count && children_iterator != directory.children.cend()) { const FileUtil::FSTEntry& file = *children_iterator; + // Directory entries are exposed to the guest as UTF-16. Normalize host UTF-8 names first + // so host Unicode normalization differences do not leak into guest-visible SDMC paths. +#if defined(__APPLE__) + const std::string filename = Common::NormalizeNFDToNFC(file.virtualName); +#else const std::string& filename = file.virtualName; +#endif + const std::u16string filename_utf16 = Common::UTF8ToUTF16(filename); Entry& entry = entries[entries_read]; LOG_TRACE(Service_FS, "File {}: size={} dir={}", filename, file.size, file.isDirectory); - // TODO(Link Mauve): use a proper conversion to UTF-16. - for (std::size_t j = 0; j < FILENAME_LENGTH; ++j) { - entry.filename[j] = filename[j]; - if (!filename[j]) - break; + std::fill(std::begin(entry.filename), std::end(entry.filename), u'\0'); + + const std::size_t copy_length = std::min(filename_utf16.size(), FILENAME_LENGTH - 1); + for (std::size_t j = 0; j < copy_length; ++j) { + entry.filename[j] = filename_utf16[j]; } FileUtil::SplitFilename83(filename, entry.short_name, entry.extension); entry.is_directory = file.isDirectory; - entry.is_hidden = (filename[0] == '.'); + entry.is_hidden = (!filename.empty() && filename[0] == '.'); entry.is_read_only = 0; entry.file_size = file.size; @@ -92,5 +101,4 @@ u32 DiskDirectory::Read(const u32 count, Entry* entries) { } return entries_read; } - } // namespace FileSys diff --git a/src/tests/common/file_util.cpp b/src/tests/common/file_util.cpp index bd7fcbdd9..72734e0a9 100644 --- a/src/tests/common/file_util.cpp +++ b/src/tests/common/file_util.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 Citra Emulator Project +// Copyright Citra Emulator Project / Azahar Emulator Project // Licensed under GPLv2 or any later version // Refer to the license.txt file included. @@ -24,3 +24,13 @@ TEST_CASE("SplitFilename83 Sanity", "[common]") { REQUIRE(std::memcmp(short_name.data(), expected_short_name.data(), short_name.size()) == 0); REQUIRE(std::memcmp(extension.data(), expected_extension.data(), extension.size()) == 0); } + +#if defined(__APPLE__) + +TEST_CASE("NormalizeNFDToNFC Sanity", "[common]") { + const std::string decomposed = "i\xCC\x81"; + const std::string composed = "\xC3\xAD"; + + REQUIRE(Common::NormalizeNFDToNFC(decomposed) == composed); +} +#endif \ No newline at end of file