diff --git a/Scripts/Bundle_MacOS.sh b/Scripts/Bundle_MacOS.sh index 57bc60e1..b0fb88be 100644 --- a/Scripts/Bundle_MacOS.sh +++ b/Scripts/Bundle_MacOS.sh @@ -65,6 +65,9 @@ if [ $? -ne 0 ] exit fi +echo "Make fileicon executable..." +chmod +x $BUNDLE_MACOS/fileicon + echo "Moving icon..." mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns diff --git a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml index ffaa7fc9..6c7c0419 100644 --- a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml @@ -2,7 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700" + mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="750" MinWidth="900" MinHeight="700" x:Class="LibationAvalonia.Dialogs.SettingsDialog" xmlns:controls="clr-namespace:LibationAvalonia.Controls" @@ -376,7 +376,7 @@ Grid.Row="3" Margin="5" VerticalAlignment="Top" - IsVisible="{Binding IsWindows}" + IsVisible="{Binding !IsLinux}" IsChecked="{Binding DownloadDecryptSettings.UseCoverAsFolderIcon, Mode=TwoWay}"> AppScaffolding.LibationScaffolding.ReleaseIdentifier is AppScaffolding.ReleaseIdentifier.WindowsAvalonia; + public bool IsLinux => Configuration.IsLinux; + public bool IsWindows => Configuration.IsWindows; public ImportantSettings ImportantSettings { get; private set; } public ImportSettings ImportSettings { get; private set; } public DownloadDecryptSettings DownloadDecryptSettings { get; private set; } diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index 4902d289..846277d3 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -73,7 +73,7 @@ namespace LibationFileManager public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName); - [Description("Set cover art as the folder's icon. (Windows only)")] + [Description("Set cover art as the folder's icon. (Windows and macOS only)")] public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); } [Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")] diff --git a/Source/LibationFileManager/WindowsDirectory.cs b/Source/LibationFileManager/WindowsDirectory.cs index 1fc0cd58..cf9ea18e 100644 --- a/Source/LibationFileManager/WindowsDirectory.cs +++ b/Source/LibationFileManager/WindowsDirectory.cs @@ -13,7 +13,7 @@ namespace LibationFileManager { try { - if (!Configuration.Instance.UseCoverAsFolderIcon || !Configuration.IsWindows) + if (!Configuration.Instance.UseCoverAsFolderIcon || Configuration.IsLinux) return; // get path of cover art in Images dir. Download first if not exists diff --git a/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj b/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj index 1d61b115..7150e29e 100644 --- a/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj +++ b/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj @@ -29,6 +29,9 @@ + + Always + Always diff --git a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs index 23f26a7c..701238cc 100644 --- a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs +++ b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs @@ -1,4 +1,5 @@ -using LibationFileManager; +using Dinah.Core; +using LibationFileManager; using System.Diagnostics; namespace MacOSConfigApp @@ -9,8 +10,14 @@ namespace MacOSConfigApp public MacOSInterop() { } public MacOSInterop(params object[] values) { } - public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); - public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException(); + public void SetFolderIcon(string image, string directory) + { + Process.Start("fileicon", $"set {directory.SurroundWithQuotes()} {image.SurroundWithQuotes()}").WaitForExit(); + } + public void DeleteFolderIcon(string directory) + { + Process.Start("fileicon", $"rm {directory.SurroundWithQuotes()}").WaitForExit(); + } //I haven't figured out how to find the app bundle's directory from within //the running process, so don't upgrade unless it's "installed" in /Applications @@ -21,7 +28,7 @@ namespace MacOSConfigApp Serilog.Log.Information($"Extracting upgrade bundle to {AppPath}"); //tar wil overwrite existing without elevated privileges - Process.Start("tar", $"-xf \"{upgradeBundle}\" -C \"/Applications\"").WaitForExit(); + Process.Start("tar", $"-xf {upgradeBundle.SurroundWithQuotes()} -C \"/Applications\"").WaitForExit(); //For now, it seems like this step is unnecessary. We can overwrite and //run Libation without needing to re-add the exception. This is insurance. diff --git a/Source/LoadByOS/MacOSConfigApp/fileicon b/Source/LoadByOS/MacOSConfigApp/fileicon new file mode 100644 index 00000000..b0f975ef --- /dev/null +++ b/Source/LoadByOS/MacOSConfigApp/fileicon @@ -0,0 +1,732 @@ +#!/usr/bin/env bash + +### +# Home page: https://github.com/mklement0/fileicon +# Author: Michael Klement (http://same2u.net) +# Invoke with: +# --version for version information +# --help for usage information +### + +# --- STANDARD SCRIPT-GLOBAL CONSTANTS + +kTHIS_NAME=${BASH_SOURCE##*/} +kTHIS_HOMEPAGE='https://github.com/mklement0/fileicon' +kTHIS_VERSION='v0.3.2' # NOTE: This assignment is automatically updated by `make version VER=` - DO keep the 'v' prefix. + +unset CDPATH # To prevent unpredictable `cd` behavior. + +# --- Begin: STANDARD HELPER FUNCTIONS + +die() { echo "$kTHIS_NAME: ERROR: ${1:-"ABORTING due to unexpected error."}" 1>&2; exit ${2:-1}; } +dieSyntax() { echo "$kTHIS_NAME: ARGUMENT ERROR: ${1:-"Invalid argument(s) specified."} Use -h for help." 1>&2; exit 2; } + +# SYNOPSIS +# openUrl +# DESCRIPTION +# Opens the specified URL in the system's default browser. +openUrl() { + local url=$1 platform=$(uname) cmd=() + case $platform in + 'Darwin') # OSX + cmd=( open "$url" ) + ;; + 'CYGWIN_'*) # Cygwin on Windows; must call cmd.exe with its `start` builtin + cmd=( cmd.exe /c start '' "$url " ) # !! Note the required trailing space. + ;; + 'MINGW32_'*) # MSYS or Git Bash on Windows; they come with a Unix `start` binary + cmd=( start '' "$url" ) + ;; + *) # Otherwise, assume a Freedesktop-compliant OS, which includes many Linux distros, PC-BSD, OpenSolaris, ... + cmd=( xdg-open "$url" ) + ;; + esac + "${cmd[@]}" || { echo "Cannot locate or failed to open default browser; please go to '$url' manually." >&2; return 1; } +} + +# Prints the embedded Markdown-formatted man-page source to stdout. +printManPageSource() { + /usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/ { s///; t\np;}' "$BASH_SOURCE" +} + +# Opens the man page, if installed; otherwise, tries to display the embedded Markdown-formatted man-page source; if all else fails: tries to display the man page online. +openManPage() { + local pager embeddedText + if ! man 1 "$kTHIS_NAME" 2>/dev/null; then + # 2nd attempt: if present, display the embedded Markdown-formatted man-page source + embeddedText=$(printManPageSource) + if [[ -n $embeddedText ]]; then + pager='more' + command -v less &>/dev/null && pager='less' # see if the non-standard `less` is available, because it's preferable to the POSIX utility `more` + printf '%s\n' "$embeddedText" | "$pager" + else # 3rd attempt: open the the man page on the utility's website + openUrl "${kTHIS_HOMEPAGE}/doc/${kTHIS_NAME}.md" + fi + fi +} + +# Prints the contents of the synopsis chapter of the embedded Markdown-formatted man-page source for quick reference. +printUsage() { + local embeddedText + # Extract usage information from the SYNOPSIS chapter of the embedded Markdown-formatted man-page source. + embeddedText=$(/usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/!d; /^## SYNOPSIS$/,/^#/{ s///; t\np; }' "$BASH_SOURCE") + if [[ -n $embeddedText ]]; then + # Print extracted synopsis chapter - remove backticks for uncluttered display. + printf '%s\n\n' "$embeddedText" | tr -d '`' + else # No SYNOPIS chapter found; fall back to displaying the man page. + echo "WARNING: usage information not found; opening man page instead." >&2 + openManPage + fi +} + +# --- End: STANDARD HELPER FUNCTIONS + +# --- PROCESS STANDARD, OUTPUT-INFO-THEN-EXIT OPTIONS. +case $1 in + --version) + # Output version number and exit, if requested. + ver="v0.3.2"; echo "$kTHIS_NAME $kTHIS_VERSION"$'\nFor license information and more, visit '"$kTHIS_HOMEPAGE"; exit 0 + ;; + -h|--help) + # Print usage information and exit. + printUsage; exit + ;; + --man) + # Display the manual page and exit. + openManPage; exit + ;; + --man-source) # private option, used by `make update-doc` + # Print raw, embedded Markdown-formatted man-page source and exit + printManPageSource; exit + ;; + --home) + # Open the home page and exit. + openUrl "$kTHIS_HOMEPAGE"; exit + ;; +esac + +# --- Begin: SPECIFIC HELPER FUNCTIONS + +# NOTE: The functions below operate on byte strings such as the one above: +# A single single string of pairs of hex digits, without separators or line breaks. +# Thus, a given byte position is easily calculated: to get byte $byteIndex, use +# ${byteString:byteIndex*2:2} + +# Outputs the specified EXTENDED ATTRIBUTE VALUE as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000A2C". +# IMPORTANT: Hex. digits > 9 use UPPPERCASE characters. +# getAttribByteString +getAttribByteString() { + xattr -px "$2" "$1" | tr -d ' \n' + return ${PIPESTATUS[0]} +} + +# Outputs the specified file's RESOURCE FORK as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000a2c". +# IMPORTANT: Hex. digits > 9 use *lowercase* characters. +# Note: This function relies on `xxd -p /..namedfork/rsrc | tr -d '\n'` rather than the conceptually equivalent call, +# `getAttribByteString com.apple.ResourceFork`, for PERFORMANCE reasons: +# getAttribByteString() (defined above) relies on `xattr`, which is a *Python* script [!! seemingly no longer, as of macOS 10.16] +# and therefore quite slow due to Python's startup cost. +# getResourceByteString +getResourceByteString() { + xxd -p "$1"/..namedfork/rsrc | tr -d '\n' +} + +# Patches a single byte in the byte string provided via stdin. +# patchByteInByteString ndx byteSpec +# ndx is the 0-based byte index +# - If has NO prefix: becomes the new byte +# - If has prefix '|': "adds" the value: the result of a bitwise OR with the existing byte becomes the new byte +# - If has prefix '~': "removes" the value: the result of a applying a bitwise AND with the bitwise complement of to the existing byte becomes the new byte +patchByteInByteString() { + local ndx=$1 byteSpec=$2 byteVal byteStr charPos op='' charsBefore='' charsAfter='' currByte + byteStr=$( 0 && charPos < ${#byteStr} )) || return 1 + # Determine the target byte, and strings before and after the byte to patch. + (( charPos >= 2 )) && charsBefore=${byteStr:0:charPos} + charsAfter=${byteStr:charPos + 2} + # Determine the new byte value + if [[ -n $op ]]; then + currByte=${byteStr:charPos:2} + printf -v patchedByte '%02X' "$(( 0x${currByte} $op 0x${byteVal} ))" + else + patchedByte=$byteSpec + fi + printf '%s%s%s' "$charsBefore" "$patchedByte" "$charsAfter" +} + +# hasAttrib +hasAttrib() { + xattr "$1" | /usr/bin/grep -Fqx "$2" +} + +# hasIconData +# Test if the file has a resource fork with icon data in it, or, +# in the case of a .VolumeIcon.icns file, has icon data *as the file contents* +hasIconData() { + local file=$1 + if [[ $(basename "$file") == $kFILENAME_VOLUMECUSTOMICON ]]; then + # special file for any folder that is a *volume mountpoint*: has the icon data as the file's *content* + file "$file" | /usr/bin/grep -Fq ' Mac OS X icon' + else + # file itself or special helper file $'Icon\r' for a regular folder: has the icon data *in its resource fork*. + getResourceByteString "$file" | /usr/bin/grep -Fq "$kMAGICBYTES_ICNS_RESOURCE" + fi +} + +# isVolumeMountPoint +isVolumeMountPoint() { + local folder=$1 + # Must resolve to the physical underlying path, as that is what `mount` shows + folder=$(cd -P -- "$1"; pwd) + mount | grep -qF "on $folder (" # !! Is there a more robust way to test for mountpoints? +} + +# getFileWithIconData +# Returns the path of the file that contains the actual icon data, based on whether the target is +# * a file ... the file path itself +# * a folder: +# * regular folder: file $'Icon\r' inside that folder, with the icon data in its resource fork +# * volume mountpoint: file '.VolumeIcon.icns' inside that folder, with the icon data inside the file. +getFileWithIconData() { + local fileOrFolder=$1 + if [[ -f $fileOrFolder ]]; then # file + printf '%s' "$fileOrFolder" + elif isVolumeMountPoint "$fileOrFolder"; then # volume mountpoint + printf '%s' "$fileOrFolder/$kFILENAME_VOLUMECUSTOMICON" + else # regular folder + printf '%s' "$fileOrFolder/$kFILENAME_FOLDERCUSTOMICON" + fi +} + +# getTargetType +# Returns a descriptor for the specified target path: +# * a file ... 'file' +# * a folder: +# * regular folder: 'folder' +# * volume mountpoint: 'volume' +getTargetType() { + local fileOrFolder=$1 + if [[ -f $fileOrFolder ]]; then # file + printf 'file' + elif isVolumeMountPoint "$fileOrFolder"; then # volume mountpoint + printf 'volume' + else # regular folder + printf 'folder' + fi +} + + +# setCustomIcon +# Tips for debugging: +# * To exercise this function, from the repo dir.: +# touch /tmp/tf; ./bin/fileicon set /tmp/tf ./test/.fixtures/img.png +# !!??? VOLUME SUPPORT, as of macOS 13.1: +# !! * While targeting volume root folders is now supported *in principle*, +# !! assigning the 'com.apple.FinderInfo' extended attribute to the mountpoint folder +# !! typically (always??) fails, so the icon doesn't take effect. +# !! SetFile -a C also fails - with or without `sudo` - with "ERROR: Unexpected Error. (-5000) on file: " +# !! (SetFile -a c clears the custom-icon flag; note that SetFile isn't installed by default and is DEPRECATED: "Tools supporting Carbon development, including /usr/bin/SetFile, were deprecated with Xcode 6.") +# !! ?? It feels like at *some point* on 13.1 our NFS mount from our NAS seemed to support it, but inexplicably no longer. +# !! * SOME volumes, even if *network* volumes, support custom icons for their files and folders, +# !! such as a SMB mount. +# !! ?? Our NFS mount from our NAS seemed to support that *for a while* on 13.1, but inexplicably no longer. +setCustomIcon() { + + local fileOrFolder=$1 imgFile=$2 fileWithIconData + + [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 3 + [[ -f $imgFile ]] || return 3 + + # !! Sadly, Apple decided to remove the `-i` / `--addicon` option from the `sips` utility. + # !! Therefore, use of *Cocoa* is required, which we do *via AppleScript* and its ObjC bridge, + # !! which has the added advantage of creating a *set* of icons from the source image, scaling as necessary + # !! to create a 512 x 512 top resolution icon (whereas sips -i created a single, 128 x 128 icon). + # !! Thanks: + # !! * https://apple.stackexchange.com/a/161984/28668 (Python original) + # !! * @scriptingosx (https://github.com/mklement0/fileicon/issues/32#issuecomment-1074124748) (AppleScript-ObjC version) + # !! Note: We moved from Python to AppleScript when the system Python was removed in macOS 12.3 + + # !! Note: The setIcon method seemingly always indicates True, even with invalid image files, so + # !! we attempt no error handling in the AppleScript code, and instead verify success explicitly later. + osascript </dev/null || die + use framework "Cocoa" + + set sourcePath to "$imgFile" + set destPath to "$fileOrFolder" + + set imageData to (current application's NSImage's alloc()'s initWithContentsOfFile:sourcePath) + (current application's NSWorkspace's sharedWorkspace()'s setIcon:imageData forFile:destPath options:2) +EOF + + # Fully verify that everything worked as intended. + # Unfortunately, several things can go wrong. + testForCustomIcon "$targetFileOrFolder" 2>/dev/null && return 0 + ec=$? + + if (( ec == 1 )); then + cat >&2 <&2 <&2 < +getCustomIcon() { + + local fileOrFolder=$1 icnsOutFile=$2 byteStr fileWithIconData byteOffset byteCount + + [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3 + + # Determine what file to extract the resource fork from. + if [[ -d $fileOrFolder ]]; then + + fileWithIconData=$(getFileWithIconData "$fileOrFolder") + [[ -f $fileWithIconData ]] || { echo "Custom-icon file does not exist: '${fileWithIconData/$'\r'/\\r}'" >&2; return 1; } + + if [[ $(basename "$fileWithIconData") == $kFILENAME_VOLUMECUSTOMICON ]]; then + # !! Volume mount points are an exception: their helper file contains the icon data as the file's *content* + # !! rather than in a *resource fork*; therefore, simply *copying* the file's content is enough. + # !! However, we use `cat` rather than `cp`, so as not to also copy the extended attributes. + cat "$fileWithIconData" > "$icnsOutFile" || die + return 0 + fi + # Otherwise: proceed below to extract the data from the resource fork. + + else + fileWithIconData=$fileOrFolder + fi + + # Determine (based on format description at https://en.wikipedia.org/wiki/Apple_Icon_Image_format): + # - the byte offset at which the icns resource begins, via the magic literal identifying an icns resource + # - the length of the resource, which is encoded in the 4 bytes right after the magic literal. + read -r byteOffset byteCount < <(getResourceByteString "$fileWithIconData" | /usr/bin/awk -F "$kMAGICBYTES_ICNS_RESOURCE" '{ printf "%s %d", (length($1) + 2) / 2, "0x" substr($2, 0, 8) }') + (( byteOffset > 0 && byteCount > 0 )) || { echo "Custom-icon file contains no icons resource: '${fileWithIconData/$'\r'/\\r}'" >&2; return 1; } + + # Extract the actual bytes using tail and head and save them to the output file. + tail -c "+${byteOffset}" "$fileWithIconData/..namedfork/rsrc" | head -c $byteCount > "$icnsOutFile" || return + + return 0 +} + +# removeCustomIcon +removeCustomIcon() { + + local fileOrFolder=$1 byteStr + + [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 1 + + # Step 1: Turn off the custom-icon flag in the com.apple.FinderInfo extended attribute. + # Note: Using SetFile -a c is tempting, but SetFile doesn't come with macOS by default (part of XCode CLI package) + if hasAttrib "$fileOrFolder" com.apple.FinderInfo; then + byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo | patchByteInByteString $kFI_BYTEOFFSET_CUSTOMICON '~'$kFI_VAL_CUSTOMICON) || return + if [[ $byteStr == "$kFI_BYTES_BLANK" ]]; then # All bytes cleared? Remove the entire attribute. + xattr -d com.apple.FinderInfo "$fileOrFolder" + else # Update the attribute. + xattr -wx com.apple.FinderInfo "$byteStr" "$fileOrFolder" || return + fi + fi + + # Step 2: Remove the resource fork (if target is a file) / hidden file with custom icon (if target is a folder) + if [[ -d $fileOrFolder ]]; then # folder or volume -> remove the special file inside it. + rm -f "$(getFileWithIconData "$fileOrFolder")" + else # file -> remove the resource fork + if hasIconData "$fileOrFolder"; then + xattr -d com.apple.ResourceFork "$fileOrFolder" + fi + fi + + return 0 +} + +# testForCustomIcon +testForCustomIcon() { + + local fileOrFolder=$1 byteStr byteVal fileWithIconData hasCustomIconFlag hasIconData + + [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3 + + # Step 1: Check if the com.apple.FinderInfo extended attribute has the custom-icon + # flag set. This applies to *all* target types. + byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo 2>/dev/null) # || return 1 + byteVal=${byteStr:2*kFI_BYTEOFFSET_CUSTOMICON:2} + hasCustomIconFlag=$(( byteVal & kFI_VAL_CUSTOMICON )) + + fileWithIconData=$(getFileWithIconData "$fileOrFolder") + # Step 2: Check if there's actual icon data present, + # via the resource fork of the file or the folder's helper file or the file content of a + # volume mountpoint's helper file (./.VolumeIcon.icns) + hasIconData "$fileWithIconData" 2>/dev/null && hasIconData=1 || hasIconData=0 + + # Provide a specific exit code reflecting the state. + # !! This is used by setCustomIcon() + if (( hasCustomIconFlag && hasIconData )); then + return 0 # has custom icon + elif (( ! hasCustomIconFlag && ! hasIconData )); then + return 1 # typical case of file/folder NOT having a custom icon + elif (( ! hasCustomIconFlag )); then + echo "WARNING: Custom-icon data is present, but the 'com.apple.FinderInfo' extended attribute isn't set for $(getTargetType "$fileOrFolder") '$fileOrFolder'" >&2 + return 2 # broken state: has icons, but no custom flag + else # (( ! hasIconData )) + echo "WARNING: While the 'com.apple.FinderInfo' extended attribute is set for $(getTargetType "$fileOrFolder") '$fileOrFolder', associated icon data is missing." >&2 + return 3 # broken state: has custom flag, but no icons + fi + +} + +# --- End: SPECIFIC HELPER FUNCTIONS + +# --- Begin: SPECIFIC SCRIPT-GLOBAL CONSTANTS + +kFILENAME_FOLDERCUSTOMICON=$'Icon\r' # the helper file for regular folders, with the actual icon image data in its *resource fork* +kFILENAME_VOLUMECUSTOMICON='.VolumeIcon.icns' # the helper file for volume mountpoints, with the actual icon image data in the file's *content*. + +# The blank hex dump form (single string of pairs of hex digits) of the 32-byte data structure stored in extended attribute +# com.apple.FinderInfo +kFI_BYTES_BLANK='0000000000000000000000000000000000000000000000000000000000000000' + +# [UPDATE] +# * THIS CONSTANT ISN'T USED ANYMORE. +# * Also, on macOS 13 (Ventura): seemingly, the Icon\r file's com.apple.FinderInfo extended attribute is now +# SIMPLER: where the folder itself has 0x4 in its 9th byte, Icon\r now has 0x40 +# [ORIGINAL COMMENT] +# The hex dump form of the full 32 bytes that Finder assigns to the hidden $'Icon\r' +# file whose com.apple.ResourceFork extended attribute contains the icon image data for the enclosing folder. +# The first 8 bytes spell out the magic literal 'iconMACS'; they are followed by the invisibility flag, '40' in the 9th byte, and '10' (?? specifying what?) +# in the 10th byte. +# NOTE: Since file $'Icon\r' serves no other purpose than to store the icon, it is +# safe to simply assign all 32 bytes blindly, without having to worry about +# preserving existing values. +# kFI_BYTES_CUSTOMICONFILEFORFOLDER='69636F6E4D414353401000000000000000000000000000000000000000000000' + +# The hex dump form of the magic literal inside a resource fork that marks the +# start of an icns (icons) resource. +# NOTE: This will be used with `xxd -p .. | tr -d '\n'`, which uses *lowercase* +# hex digits, so we must use lowercase here. +kMAGICBYTES_ICNS_RESOURCE='69636e73' + +# The byte values (as hex strings) of the flags at the relevant byte position +# of the com.apple.FinderInfo extended attribute. +kFI_VAL_CUSTOMICON='04' + +# The custom-icon-flag byte offset in the com.apple.FinderInfo extended attribute. +kFI_BYTEOFFSET_CUSTOMICON=8 + +# --- End: SPECIFIC SCRIPT-GLOBAL CONSTANTS + +# Option defaults. +force=0 quiet=0 + +# --- Begin: OPTIONS PARSING +allowOptsAfterOperands=1 operands=() i=0 optName= isLong=0 prefix= optArg= haveOptArgAttached=0 haveOptArgAsNextArg=0 acceptOptArg=0 needOptArg=0 +while (( $# )); do + if [[ $1 =~ ^(-)[a-zA-Z0-9]+.*$ || $1 =~ ^(--)[a-zA-Z0-9]+.*$ ]]; then # an option: either a short option / multiple short options in compressed form or a long option + prefix=${BASH_REMATCH[1]}; [[ $prefix == '--' ]] && isLong=1 || isLong=0 + for (( i = 1; i < (isLong ? 2 : ${#1}); i++ )); do + acceptOptArg=0 needOptArg=0 haveOptArgAttached=0 haveOptArgAsNextArg=0 optArgAttached= optArgOpt= optArgReq= + if (( isLong )); then # long option: parse into name and, if present, argument + optName=${1:2} + [[ $optName =~ ^([^=]+)=(.*)$ ]] && { optName=${BASH_REMATCH[1]}; optArgAttached=${BASH_REMATCH[2]}; haveOptArgAttached=1; } + else # short option: *if* it takes an argument, the rest of the string, if any, is by definition the argument. + optName=${1:i:1}; optArgAttached=${1:i+1}; (( ${#optArgAttached} >= 1 )) && haveOptArgAttached=1 + fi + (( haveOptArgAttached )) && optArgOpt=$optArgAttached optArgReq=$optArgAttached || { (( $# > 1 )) && { optArgReq=$2; haveOptArgAsNextArg=1; }; } + # ---- BEGIN: CUSTOMIZE HERE + case $optName in + f|force) + force=1 + ;; + q|quiet) + quiet=1 + ;; + *) + dieSyntax "Unknown option: ${prefix}${optName}." + ;; + esac + # ---- END: CUSTOMIZE HERE + (( needOptArg )) && { (( ! haveOptArgAttached && ! haveOptArgAsNextArg )) && dieSyntax "Option ${prefix}${optName} is missing its argument." || (( haveOptArgAsNextArg )) && shift; } + (( acceptOptArg || needOptArg )) && break + done + else # an operand + if [[ $1 == '--' ]]; then + shift; operands+=( "$@" ); break + elif (( allowOptsAfterOperands )); then + operands+=( "$1" ) # continue + else + operands=( "$@" ) + break + fi + fi + shift +done +(( "${#operands[@]}" > 0 )) && set -- "${operands[@]}"; unset allowOptsAfterOperands operands i optName isLong prefix optArgAttached haveOptArgAttached haveOptArgAsNextArg acceptOptArg needOptArg +# --- End: OPTIONS PARSING: "$@" now contains all operands (non-option arguments). + +# Validate the command +cmd=$(printf %s "$1" | tr '[:upper:]' '[:lower:]') # translate to all-lowercase - we don't want the command name to be case-sensitive +[[ $cmd == 'remove' ]] && cmd='rm' # support alias 'remove' for 'rm' +case $cmd in + set|get|rm|remove|test) + shift + ;; + *) + dieSyntax "Unrecognized or missing command: '$cmd'." + ;; +esac + +# Validate file operands +(( $# > 0 )) || dieSyntax "Missing operand(s)." + +# Target file or folder. +targetFileOrFolder=$1 imgFile= outFile= +[[ -f $targetFileOrFolder || -d $targetFileOrFolder ]] || die "Target not found or neither file nor folder: '$targetFileOrFolder'" +# Make sure the target file/folder is readable, and, unless only getting or testing for an icon are requested, writeable too. +[[ -r $targetFileOrFolder ]] || die "Cannot access '$targetFileOrFolder': you do not have read permissions." +[[ $cmd == 'test' || $cmd == 'get' || -w $targetFileOrFolder ]] || die "Cannot modify '$targetFileOrFolder': you do not have write permissions." + +# Other operands, if any, and their number. +valid=0 +case $cmd in + 'set') + (( $# <= 2 )) && { + valid=1 + # If no image file was specified, the target file is assumed to be an image file itself whose image should be self-assigned as an icon. + (( $# == 2 )) && imgFile=$2 || imgFile=$1 + # !! Apparently, a regular file is required - a process subsitution such + # !! as `<(base64 -D /dev/null + else + testForCustomIcon "$targetFileOrFolder" # This may issue warnings. + fi + ec=$? + if (( ! quiet )); then + echo "$( (( ec == 0 )) && printf 'HAS' || printf 'Has NO' ) custom icon: $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder'" + fi + exit $ec + ;; + *) + die "DESIGN ERROR: unanticipated command: $cmd" + ;; +esac + +exit 0 + +#### +# MAN PAGE MARKDOWN SOURCE +# - Place a Markdown-formatted version of the man page for this script +# inside the here-document below. +# The document must be formatted to look good in all 3 viewing scenarios: +# - as a man page, after conversion to ROFF with marked-man +# - as plain text (raw Markdown source) +# - as HTML (rendered Markdown) +# Markdown formatting tips: +# - GENERAL +# To support plain-text rendering in the terminal, limit all lines to 80 chars., +# and, for similar rendering as HTML, *end every line with 2 trailing spaces*. +# - HEADINGS +# - For better plain-text rendering, leave an empty line after a heading +# marked-man will remove it from the ROFF version. +# - The first heading must be a level-1 heading containing the utility +# name and very brief description; append the manual-section number +# directly to the CLI name; e.g.: +# # foo(1) - does bar +# - The 2nd, level-2 heading must be '## SYNOPSIS' and the chapter's body +# must render reasonably as plain text, because it is printed to stdout +# when `-h`, `--help` is specified: +# Use 4-space indentation without markup for both the syntax line and the +# block of brief option descriptions; represent option-arguments and operands +# in angle brackets; e.g., '' +# - All other headings should be level-2 headings in ALL-CAPS. +# - TEXT +# - Use NO indentation for regular chapter text; if you do, it will +# be indented further than list items. +# - Use 4-space indentation, as usual, for code blocks. +# - Markup character-styling markup translates to ROFF rendering as follows: +# `...` and **...** render as bolded (red) text +# _..._ and *...* render as word-individually underlined text +# - LISTS +# - Indent list items by 2 spaces for better plain-text viewing, but note +# that the ROFF generated by marked-man still renders them unindented. +# - End every list item (bullet point) itself with 2 trailing spaces too so +# that it renders on its own line. +# - Avoid associating more than 1 paragraph with a list item, if possible, +# because it requires the following trick, which hampers plain-text readability: +# Use ' ' in lieu of an empty line. +#### +: <<'EOF_MAN_PAGE' +# fileicon(1) - manage file and folder custom icons + +## SYNOPSIS + +Manage custom icons for files and folders on macOS. + +SET a custom icon for a file or folder: + + fileicon set [] + +REMOVE a custom icon from a file or folder: + + fileicon rm + +GET a file or folder's custom icon: + + fileicon get [-f] [] + + -f ... force replacement of existing output file + +TEST if a file or folder has a custom icon: + + fileicon test + +All forms: option -q silences status output. + +Standard options: `--help`, `--man`, `--version`, `--home` + +## DESCRIPTION + +`` is the file or folder whose custom icon should be managed. +Note that symlinks are followed to their (ultimate target); that is, you +can only assign custom icons to regular files and folders, not to symlinks +to them. + +`` can be an image file of any format supported by the system. +It is converted to an icon and assigned to ``. +If you omit ``, `` must itself be an image file whose +image should become its own icon. + +`` specifies the file to extract the custom icon to: +Defaults to the filename of `` with extension `.icns` appended. +If a value is specified, extension `.icns` is appended, unless already present. +Either way, extraction fails if the target file already exists; use `-f` to +override. +Specify `-` to extract to stdout. + +Command `test` signals with its exit code whether a custom icon is set (0) +or not (1); any other exit code signals an unexpected error. + +**Options**: + + * `-f`, `--force` + When getting (extracting) a custom icon, forces replacement of the + output file, if it already exists. + + * `-q`, `--quiet` + Suppresses output of the status information that is by default output to + stdout. + Note that errors and warnings are still printed to stderr. + +## NOTES + +Custom icons are stored in extended attributes of the HFS+ filesystem. +Thus, if you copy files or folders to a different filesystem that doesn't +support such attributes, custom icons are lost; for instance, custom icons +cannot be stored in a Git repository. + +To determine if a give file or folder has extended attributes, use +`ls -l@ `. + +When setting an image as a custom icon, a set of icons with several resolutions +is created, with the highest resolution at 512 x 512 pixels. + +All icons created are square, so images with a non-square aspect ratio will +appear distorted; for best results, use square imges. + +## STANDARD OPTIONS + +All standard options provide information only. + +* `-h, --help` + Prints the contents of the synopsis chapter to stdout for quick reference. + +* `--man` + Displays this manual page, which is a helpful alternative to using `man`, + if the manual page isn't installed. + +* `--version` + Prints version information. + +* `--home` + Opens this utility's home page in the system's default web browser. + +## LICENSE + +For license information and more, visit the home page by running +`fileicon --home` + +EOF_MAN_PAGE diff --git a/Source/LoadByOS/WindowsConfigApp/FolderIcon.cs b/Source/LoadByOS/WindowsConfigApp/FolderIcon.cs index e35eb57c..8af33718 100644 --- a/Source/LoadByOS/WindowsConfigApp/FolderIcon.cs +++ b/Source/LoadByOS/WindowsConfigApp/FolderIcon.cs @@ -63,10 +63,10 @@ namespace WindowsConfigApp // https://github.com/dimuththarindu/FIC-Folder-Icon-Changer/blob/master/project/FIC/Classes/IconCustomizer.cs - public static void SetIcon(this DirectoryInfo directoryInfo, string icoPath, string folderType) - => SetIcon(directoryInfo.FullName, icoPath, folderType); + public static void SetIcon(this DirectoryInfo directoryInfo, byte[] icon, string folderType) + => SetIcon(directoryInfo.FullName, icon, folderType); - public static void SetIcon(string dir, string icoPath, string folderType) + public static void SetIcon(string dir, byte[] icon, string folderType) { var desktop_ini = Path.Combine(dir, "desktop.ini"); var Icon_ico = Path.Combine(dir, "Icon.ico"); @@ -76,7 +76,7 @@ namespace WindowsConfigApp DeleteIcon(dir); //copying Icon file //overwriting - File.Copy(icoPath, Icon_ico, true); + File.WriteAllBytes(Icon_ico, icon); //writing configuration file string[] desktopLines = { "[.ShellClassInfo]", "IconResource=Icon.ico,0", "[ViewState]", "Mode=", "Vid=", $"FolderType={folderType}" }; diff --git a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs index 5aef4bea..fda076c5 100644 --- a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs +++ b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs @@ -18,11 +18,8 @@ namespace WindowsConfigApp try { - var icon = Image.Load(File.ReadAllBytes(image)).ToIcon(); - iconPath = Path.Combine(directory, $"{Guid.NewGuid()}.ico"); - File.WriteAllBytes(iconPath, icon); - - new DirectoryInfo(directory)?.SetIcon(iconPath, "Music"); + var icon = Image.Load(image).ToIcon(); + new DirectoryInfo(directory)?.SetIcon(icon, "Music"); } finally {