Skip to content

Instantly share code, notes, and snippets.

@0xfeedface1993
Last active February 19, 2024 18:45
Show Gist options
  • Save 0xfeedface1993/12b74aa8a2fc87e47e15a58f8d5549a6 to your computer and use it in GitHub Desktop.
Save 0xfeedface1993/12b74aa8a2fc87e47e15a58f8d5549a6 to your computer and use it in GitHub Desktop.
Workaround Xcode 15's "Replace Container" feature replaces the container with incorrect permissions
  1. Enable Apache2 on your mac server, nano /etc/apache2/httpd.conf.
DocumentRoot "/Library/WebServer/Documents"
<Directory "/Library/WebServer/Documents">
    # add Indexes
    Options FollowSymLinks Multiviews Indexes
    MultiviewsMatch Any
    AllowOverride None
    Require all granted
</Directory>

# Uncommit this module
LoadModule autoindex_module libexec/apache2/mod_autoindex.so

then execute sudo apachectl restart restart Apache2.
2. Download Container from Xcode. it shoud be save as com.xxxx.xcappdata. 3. Add/Edit/Remove file in com.xxxx.xcappdata/AppData/Documents/ (Most ussage) 4. Create client-upload-app-documents.sh and app-documents-sync.sh at same folder, replace those variable with your setting:

client-upload-app-documents.sh

##!/bin/bash

set -x

REMOTE_USER=YourServerUser
REMOTE_FILE_PATH=/Users/$REMOTE_USER/Downloads
REMOTE_HOST=YourServerIPorDomain(like 192.168.1.100 or file.yoursite.com)

function updateFiles() {
    local SourcePath=$1
    local DestinationPath=$2
    local ScriptPath=$3
    local AppName=$4
    local SudoPassword=$5
    local Random_String=$(openssl rand -hex 4)
    local Remote_Source_Path=$REMOTE_FILE_PATH/tmp_$Random_String

    scp $ScriptPath $REMOTE_USER@$REMOTE_HOST:$DestinationPath
    scp -r $SourcePath/* $REMOTE_USER@$REMOTE_HOST:$Remote_Source_Path

    ssh $REMOTE_USER@$REMOTE_HOST "cd $DestinationPath; chmod +x $ScriptPath; ./$ScriptPath -d $Remote_Source_Path -a $AppName -p $SudoPassword; rm -rf $Remote_Source_Path"
}

# command line arguments
# -d [source path]
# -a [app name]
# -s [script path]
# -p [sudo password]
while getopts d:a:s:p: option
do
    case "${option}"
    in
        d) sourcePath=${OPTARG};;
        a) appName=${OPTARG};;
        s) scriptPath=${OPTARG};;
        p) sudoPassword=${OPTARG};;
    esac
done

if [ -z "$sudoPassword" ]; then
    echo "sudo password is empty, exiting..."
    exit 1
fi

if [ -z "$sourcePath" ]; then
    echo "Source path is empty, exiting..."
    exit 1
fi

if [ -z "$appName" ]; then
    echo "App name is empty, exiting..."
    exit 1
fi

if [ -z "$scriptPath" ]; then
    echo "Script path is empty, exiting..."
    exit 1
fi

updateFiles $sourcePath $REMOTE_FILE_PATH $scriptPath $appName $sudoPassword

app-documents-sync.sh

#!/bin/bash

set -x

AppPath=/Library/WebServer/Documents/App

function copyFilesToDocuments() {
    local SourcePath=$1
    local AppName=$2
    local App_Documents_Path=$AppPath/$AppName/Documents

    if [ -z "$SourcePath" ]; then
        echo "Source path is empty, exiting..."
        exit 1
    fi

    if [ ! -d "$App_Documents_Path" ] && [ ! -z "$AppName" ]; then
        echo "App directory for app $AppName does not exist, creating it now..."
        sudo mkdir -p $App_Documents_Path
        # sudo chown -R _www:_www $App_Documents_Path
        # sudo chmod -R 755 $App_Documents_Path
    fi

    echo "Copying files in $SourcePath to $App_Documents_Path... (Not top level folder)"
    sudo cp -R $SourcePath/* $App_Documents_Path
    echo "Done."
}

# command line arguments
# -d [source path]
# -a [app name]
# -p [sudo password]
while getopts d:a:p: option
do
    case "${option}"
    in
        d) sourcePath=${OPTARG};;
        a) appName=${OPTARG};;
        p) sudoPassword=${OPTARG};;
    esac
done

if [ ! -z "$sudoPassword" ]; then
    echo $sudoPassword | sudo -S -v
fi

if [ -z "$sourcePath" ]; then
    echo "Source path is empty, exiting..."
    exit 1
fi

if [ -z "$appName" ]; then
    echo "App name is empty, exiting..."
    exit 1
fi

if [ ! -d "$AppPath" ]; then
  echo "Documents directory does not exist, creating it now..."
  sudo mkdir -p $AppPath
  sudo chown -R _www:_www $AppPath
  sudo chmod -R 755 $AppPath
fi

copyFilesToDocuments $sourcePath $appName
  1. Excute client-upload-app-documents.sh, upload or mv Documents files under Apache default root path /Library/WebServer/Documents/App/YourAppName/Documents, you can name anything to YourAppName, YourServerPassword for sudo command mv file to /Library/WebServer/Documents:
/path/to/client-upload-app-documents.sh -d /path/to/com.xxxx.xcappdata/AppData/Documents -a YourAppName -s app-documents-sync.sh -p YourServerPassword

check it by browsing http://YourServerIPorDomain:8080/App/YourAppName/Documents, it shuld be no error.

  1. Now, back to your App project, Add ContainerMixer.swift:
import Foundation
import Logging

internal let logger = Logger(label: "com.container.mixer")

public protocol FileProvider {
    func files(in path: String) async throws -> [URL]
    func baseURL() throws -> URL
}

public enum FileDomain {
    case documents
    
    var folder: String {
        switch self {
        case .documents:
            return "Documents"
        }
    }
    
    var directory: FileManager.SearchPathDirectory {
        switch self {
        case .documents:
            return .documentDirectory
        }
    }
}

public struct ContainerMixer {
    private let provider: FileProvider
    public var domain = FileDomain.documents
    
    public init(_ provider: FileProvider) {
        self.provider = provider
    }
    
    public func download() async throws -> [URL] {
        let folder = domain.folder
        let files = try await provider.files(in: folder)
        var temp = [URL]()
        for file in files {
            temp += try await processFileURL(file)
        }
        return temp
    }
    
    func processFileURL(_ file: URL) async throws -> [URL] {
        var temp = [URL]()
        if file.absoluteString.hasSuffix("/") {
            let next = try await provider.files(in: file.path)
            for item in next {
                let urls = try await processFileURL(item)
                temp += urls
            }
        }   else    {
            let url = try await reloadFile(file)
            temp.append(url)
        }
        return temp
    }
    
    func reloadFile(_ file: URL) async throws -> URL {
        let base = try provider.baseURL()
        let relativePath = file.path
        guard let request = URL(string: relativePath, relativeTo: base) else {
            throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "\(base)\(relativePath)"])
        }
        let (url, _) = try await URLSession.shared.download(from: URLRequest(url: request))
        let destination = try FileManager.default.url(for: domain.directory, in: .userDomainMask, appropriateFor: nil, create: false)
        let subDirectory = relativePath.hasPrefix(destination.lastPathComponent) ? destination.deletingLastPathComponent():destination
        let next = URL(fileURLWithPath: relativePath, relativeTo: subDirectory)
        if FileManager.default.fileExists(atPath: next.path) {
            logger.info("Removing \(next.path)")
            try FileManager.default.removeItem(at: next)
        }   else    {
            let parent = next.deletingLastPathComponent()
            if !FileManager.default.fileExists(atPath: parent.path) {
                try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
                logger.info("Creating \(parent.path)")
            }
        }
        try FileManager.default.moveItem(at: url, to: next)
        logger.info("Moving \(request.absoluteString) to \(next.path)")
        return next
    }
}
  1. Add DocumentsSwap.swift:
import Foundation

struct DocumentsSwap: FileProvider {
    func start() async {
        let mixer = ContainerMixer(self)
        await Task.log {
            let urls = try await mixer.download()
            let description = urls.map {
                "local file at \($0.path)"
            }.joined(separator: "\n")
            logger.info("downloaded and replaced \(urls.count) files\n\(description)")
        }.value
    }
    
    func files(in path: String) async throws -> [URL] {
        let base = try baseURL()
        let string =  base.absoluteString + "\(path)?F=0"
        guard let url = URL(string: string) else {
            throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: string])
        }
        let request = URLRequest(url: url)
        let (fileURL, _) = try await URLSession.shared.download(from: request)
        let raw = try String(contentsOf: fileURL)
        if #available(iOS 16.0, macOS 13.0, *) {
            let regx = try Regex("<a\\s+href=\"([^\"\\?]+)\"\\s*>")
            let strings = raw.matches(of: regx)
                .compactMap { $0[1].substring }
            let relativeURL: [URL] = strings
                .compactMap {
                    if $0.hasPrefix("/") {
                        logger.info("skip \($0), because it is parent path for \(path)")
                        return nil
                    }
                    let charactor = path.hasSuffix("/") || $0.hasPrefix("/") ? "":"/"
                    return URL(string: "\(path)\(charactor)\($0)")
                }
            logger.info("found \(relativeURL.count) files in \(path)")
            return relativeURL
        } else {
            // Fallback on earlier versions
        }
        return []
    }
    
    func baseURL() throws -> URL {
        let host = URL(string: "http://YourServerIPorDomain:8080")!
        let string =  host.absoluteString + "/App/YourAppName/"
        guard let url = URL(string: string) else {
            throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: string])
        }
        return url
    }
}
  1. Edit YourApp.swift:
@main
struct YourApp: App {
    @State var contentID = 1
    
    var body: some Scene {
        WindowGroup {
            ContentView()
#if DEBUG
                .id(contentID)
                .task {
                    await DocumentsSwap().start()
                    contentID += 1
                }
#endif
        }
    }
}

App will automatic download Documents files from our server and replace it at app lanuch.

Please!!! Apple need fix this bug ASAP.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment