Support private file exports and portable Mac resource bundles
6 files changed,
+126
−7
Makefile+4 −2README.md+16 −1Sources/RastrilloApple/NativeHTTP.swift+16 −0Tests/RastrilloAppleTests/HTTPTests.swift+23 −0tools/bundle-mac.py+12 −4tools/tests/test_bundle_mac.py+55 −0
CI failed — run details
CI log
=== test === swift test Building for debugging... [0/17] Write sources [4/17] Write swift-version--28C01585A1F674EC.txt [6/26] Emitting module RastrilloNative [7/26] Compiling RastrilloNative CoalescedRunner.swift [8/27] Compiling RastrilloApple NativeHTTP.swift /home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__docs-sheets-editors/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine' 1 | import Combine | `- error: no such module 'Combine' 2 | import CryptoKit 3 | import Foundation [10/27] Compiling RastrilloApple NativeSignInView.swift /home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__docs-sheets-editors/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine' 1 | import Combine | `- error: no such module 'Combine' 2 | import CryptoKit 3 | import Foundation [11/27] Compiling RastrilloApple NativeStreamLines.swift /home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__docs-sheets-editors/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine' 1 | import Combine | `- error: no such module 'Combine' 2 | import CryptoKit 3 | import Foundation [12/27] Compiling RastrilloApple NativeResponse.swift /home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__docs-sheets-editors/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine' 1 | import Combine | `- error: no such module 'Combine' 2 | import CryptoKit 3 | import Foundation [12/27] Wrapping AST for RastrilloNative for debugging [14/29] Emitting module RastrilloNativeTests [15/29] Compiling RastrilloNativeTests CoalescedRunnerTests.swift error: emit-module command failed with exit code 1 (use -v to see invocation) [17/30] Emitting module RastrilloApple /home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__docs-sheets-editors/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine' 1 | import Combine | `- error: no such module 'Combine' 2 | import CryptoKit 3 | import Foundation [18/30] Compiling RastrilloApple NativeCredentials.swift /home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__docs-sheets-editors/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine' 1 | import Combine | `- error: no such module 'Combine' 2 | import CryptoKit 3 | import Foundation [19/30] Compiling RastrilloApple NativeConnection.swift /home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/native__docs-sheets-editors/Sources/RastrilloApple/NativeConnection.swift:1:8: error: no such module 'Combine' 1 | import Combine | `- error: no such module 'Combine' 2 | import CryptoKit 3 | import Foundation [19/30] Wrapping AST for RastrilloNativeTests for debugging error: fatalError make: *** [Makefile:4: test] Error 1 amadan: step test failed: exit status 2 amadan: job failed at step test — its output is above, not at the tail of this log
diff --git a/Makefile b/Makefile| index 2dd95f8..61d1bf7 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -1,9 +1,11 @@ |
| -.PHONY: ci test companion |
| -ci: test companion server-test |
| +.PHONY: ci test companion bundle-test |
| +ci: test companion server-test bundle-test |
| test: |
| swift test |
| server-test: |
| cd server && go test -race ./... |
| +bundle-test: |
| + python3 tools/tests/test_bundle_mac.py |
| companion: |
| cd examples/companion && xcodegen generate |
| xcodebuild -project examples/companion/Companion.xcodeproj -scheme CompanionMac -destination 'platform=macOS' -derivedDataPath examples/companion/build CODE_SIGNING_ALLOWED=NO build |
diff --git a/README.md b/README.md| index dcb6beb..57415fa 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -69,7 +69,22 @@ allows this callback to fetch an initial snapshot without missing intervening ed |
| The consumer parses events, revalidates permissions through its server, and |
| refetches/reconnects after EOF. An unfinished event at EOF must not be applied. |
| Streams use the same no-redirect session and origin-bound credentials as ordinary |
| -requests. No response, stream or export is automatically persisted. |
| +requests. Responses and streams are not persisted. |
| + |
| +`download(_:to:)` streams an explicit user export into a new file, with the same |
| +origin and redirect restrictions. It requires HTTP 200 and a file URL, applies |
| +0600 permissions, and refuses to overwrite an existing destination. The caller |
| +owns that temporary export and must remove it after the save panel completes |
| +or is cancelled. Unlike in-memory responses, downloads have no body-size cap. |
| + |
| +`tools/bundle-mac.py` includes SwiftPM resource bundles in `Contents/Resources` |
| +and reads the minimum macOS version from the package manifest. A SwiftPM |
| +consumer with resources should first locate its resource bundle with |
| +`Bundle.main.url(forResource: "Package_Target", withExtension: "bundle")`, |
| +then lazily fall back to `Bundle.module` for command-line development builds. |
| +SwiftPM's generated accessor alone looks in the app root and the original build |
| +directory; neither is the signed Mac app's resource directory. The packaging |
| +test relocates a signed app, deletes its build tree, and verifies resource access. |
| `make ci` runs the Swift tests and compiles both companion targets. It |
| requires macOS, Xcode and XcodeGen. Builds are unsigned; this gate does not |
diff --git a/Sources/RastrilloApple/NativeHTTP.swift b/Sources/RastrilloApple/NativeHTTP.swift| index f8df215..2f1c278 100644 |
| --- a/Sources/RastrilloApple/NativeHTTP.swift |
| +++ b/Sources/RastrilloApple/NativeHTTP.swift |
| @@ -67,6 +67,22 @@ public final class NativeHTTP: @unchecked Sendable { |
| return (result.data, result.status) |
| } |
| + /// An explicit export to a new file, streamed by URLSession without retaining |
| + /// the workbook/document in memory. The caller owns deletion after export or |
| + /// cancellation of its save panel. Existing destinations are never replaced. |
| + public func download(_ path: String, to destination: URL) async throws { |
| + guard destination.isFileURL else { throw NativeClientError.message("Invalid export destination.") } |
| + let request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json") |
| + let (temporary, response) = try await session.download(for: request) |
| + defer { try? FileManager.default.removeItem(at: temporary) } |
| + try Task.checkCancellation() |
| + guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { |
| + throw NativeClientError.message("The export could not be completed. Refresh your session and try again.") |
| + } |
| + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) |
| + try FileManager.default.moveItem(at: temporary, to: destination) |
| + } |
| + |
| /// Exposes protocol cursors (for example Docs-Seq) without sharing a cookie jar. |
| public func responseDetails(_ path: String, method: String = "GET", body: Data? = nil, |
| contentType: String = "application/json", maxBytes: Int = 4 * 1024 * 1024) async throws -> NativeResponse { |
diff --git a/Tests/RastrilloAppleTests/HTTPTests.swift b/Tests/RastrilloAppleTests/HTTPTests.swift| index 2264385..f8d5fa5 100644 |
| --- a/Tests/RastrilloAppleTests/HTTPTests.swift |
| +++ b/Tests/RastrilloAppleTests/HTTPTests.swift |
| @@ -87,6 +87,29 @@ server.serve_forever() |
| XCTAssertTrue(response.data.isEmpty) |
| } |
| + func testFileExportIsPrivateAndNeverOverwritesOrFollowsRedirect() async throws { |
| + let (server, client) = try fixture() |
| + defer { stop(server) } |
| + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
| + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) |
| + defer { try? FileManager.default.removeItem(at: directory) } |
| + let file = directory.appendingPathComponent("export.xlsx") |
| + try await client.download("/bytes", to: file) |
| + XCTAssertEqual(try Data(contentsOf: file), Data("12345".utf8)) |
| + let permissions = try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber |
| + XCTAssertEqual(permissions?.intValue, 0o600) |
| + try Data("keep existing file".utf8).write(to: file) |
| + do { |
| + try await client.download("/bytes", to: file) |
| + XCTFail("Export overwrote an existing file") |
| + } catch { XCTAssertEqual(try Data(contentsOf: file), Data("keep existing file".utf8)) } |
| + let redirected = directory.appendingPathComponent("redirect.xlsx") |
| + do { |
| + try await client.download("/redirect", to: redirected) |
| + XCTFail("Export followed or accepted a redirect") |
| + } catch { XCTAssertFalse(FileManager.default.fileExists(atPath: redirected.path)) } |
| + } |
| + |
| func testLiveTransportAndWrongContentType() async throws { |
| let (server, client) = try fixture() |
| defer { stop(server) } |
diff --git a/tools/bundle-mac.py b/tools/bundle-mac.py| index 8150826..6b14f54 100644 |
| --- a/tools/bundle-mac.py |
| +++ b/tools/bundle-mac.py |
| @@ -1,17 +1,25 @@ |
| """Bundle a SwiftPM app with the suite's padded macOS icon silhouette.""" |
| from pathlib import Path |
| -import argparse, plistlib, shutil, subprocess |
| +import argparse, json, plistlib, shutil, subprocess |
| parser = argparse.ArgumentParser() |
| parser.add_argument("native", type=Path) |
| parser.add_argument("name") |
| args = parser.parse_args() |
| root = args.native.resolve() |
| name = args.name |
| -subprocess.run(["swift", "build", "--package-path", str(root / "apple")], check=True) |
| +subprocess.run(["swift", "build", "--package-path", str(root / "apple"), "--product", f"{name}Mac"], check=True) |
| +manifest = json.loads(subprocess.check_output(["swift", "package", "--package-path", str(root / "apple"), "dump-package"])) |
| +minimum_os = next((p["version"] for p in manifest.get("platforms", []) if p["platformName"] == "macos"), "14.0") |
| +products = Path(subprocess.check_output(["swift", "build", "--package-path", str(root / "apple"), "--show-bin-path"], text=True).strip()) |
| bundle = root / f"build/Oficina {name}.app/Contents" |
| for directory in ["MacOS", "Resources"]: |
| (bundle / directory).mkdir(parents=True, exist_ok=True) |
| -shutil.copy2(root / f"apple/.build/debug/{name}Mac", bundle / f"MacOS/{name}Mac") |
| +shutil.copy2(products / f"{name}Mac", bundle / f"MacOS/{name}Mac") |
| +for resource in products.glob("*.bundle"): |
| + target = bundle / "Resources" / resource.name |
| + if target.exists(): |
| + shutil.rmtree(target) |
| + shutil.copytree(resource, target) |
| shutil.copy2(root / "LICENSE.lucide", bundle / "Resources/LICENSE.lucide") |
| icon = root / "build/MacIcon.png" |
| subprocess.run(["swift", str(Path(__file__).with_name("mac-icon.swift")), str(root / "ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png"), str(icon)], check=True) |
| @@ -27,7 +35,7 @@ subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(bundle / "Reso |
| "CFBundleIdentifier": f"net.amadan.oficina.{name.lower()}", |
| "CFBundleExecutable": f"{name}Mac", "CFBundlePackageType": "APPL", |
| "CFBundleIconFile": "AppIcon.icns", "CFBundleShortVersionString": "0.1.0", |
| - "CFBundleVersion": "1", "LSMinimumSystemVersion": "14.0", |
| + "CFBundleVersion": "1", "LSMinimumSystemVersion": minimum_os, |
| "NSHighResolutionCapable": True, |
| })) |
| subprocess.run(["codesign", "--force", "--deep", "--sign", "-", str(bundle.parent)], check=True) |
diff --git a/tools/tests/test_bundle_mac.py b/tools/tests/test_bundle_mac.py| new file mode 100644 |
| index 0000000..2660bb8 |
| --- /dev/null |
| +++ b/tools/tests/test_bundle_mac.py |
| @@ -0,0 +1,55 @@ |
| +"""A packaged app must load resources after its original build tree is gone.""" |
| +import plistlib |
| +from pathlib import Path |
| +import shutil |
| +import struct |
| +import subprocess |
| +import tempfile |
| +import unittest |
| +import zlib |
| + |
| + |
| +class BundleMacTests(unittest.TestCase): |
| + def test_relocated_resource_bundle_and_deployment_target(self): |
| + tool = Path(__file__).resolve().parents[1] / "bundle-mac.py" |
| + with tempfile.TemporaryDirectory(prefix="native-bundle-test-") as temporary: |
| + root = Path(temporary) |
| + source = root / "apple/Sources/Probe" |
| + (source / "Resources").mkdir(parents=True) |
| + (root / "apple/Package.swift").write_text('''// swift-tools-version: 5.9 |
| +import PackageDescription |
| +let package = Package(name: "Probe", platforms: [.macOS("26.0")], |
| + products: [.executable(name: "ProbeMac", targets: ["Probe"])], |
| + targets: [.executableTarget(name: "Probe", resources: [.process("Resources")])]) |
| +''') |
| + (source / "main.swift").write_text('''import Foundation |
| +let resources = Bundle.main.url(forResource: "Probe_Probe", withExtension: "bundle").flatMap(Bundle.init(url:)) ?? Bundle.module |
| +guard let url = resources.url(forResource: "sentinel", withExtension: "txt"), |
| + let text = try? String(contentsOf: url, encoding: .utf8) else { exit(7) } |
| +print(text) |
| +''') |
| + (source / "Resources/sentinel.txt").write_text("portable native resources") |
| + (root / "LICENSE.lucide").write_text("Synthetic packaging test fixture") |
| + icons = root / "ios/Assets.xcassets/AppIcon.appiconset" |
| + icons.mkdir(parents=True) |
| + |
| + def chunk(kind, payload): |
| + return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload)) |
| + |
| + png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1024, 1024, 8, 6, 0, 0, 0)) |
| + png += chunk(b"IDAT", zlib.compress((b"\0" + b"\x30\x70\xb0\xff" * 1024) * 1024)) + chunk(b"IEND", b"") |
| + (icons / "AppIcon.png").write_bytes(png) |
| + subprocess.run(["python3", str(tool), str(root), "Probe"], check=True, stdout=subprocess.DEVNULL) |
| + app = root / "relocated/Probe.app" |
| + app.parent.mkdir() |
| + shutil.move(root / "build/Oficina Probe.app", app) |
| + shutil.rmtree(root / "apple") |
| + info = plistlib.loads((app / "Contents/Info.plist").read_bytes()) |
| + self.assertEqual(info["LSMinimumSystemVersion"], "26.0") |
| + output = subprocess.check_output([str(app / "Contents/MacOS/ProbeMac")], text=True) |
| + self.assertEqual(output.strip(), "portable native resources") |
| + subprocess.run(["codesign", "--verify", "--strict", "--deep", str(app)], check=True) |
| + |
| + |
| +if __name__ == "__main__": |
| + unittest.main() |