rastrillo / native Public

Support private file exports and portable Mac resource bundles

Paul Campbell pushed by paul@keymail.dev a49307c29cf36dff2cf423c3f98b38269ce9a553 parent 6ddea4d
6 files changed, +126 −7
  • Makefile +4 −2
  • README.md +16 −1
  • Sources/RastrilloApple/NativeHTTP.swift +16 −0
  • Tests/RastrilloAppleTests/HTTPTests.swift +23 −0
  • tools/bundle-mac.py +12 −4
  • tools/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()