Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,21 @@ jobs:
tar -czf "release-files/Oxbit_${version}_web.tar.gz" -C apps/web/dist .
tar -czf "release-files/Oxbit_${version}_runtime.tar.gz" -C apps/runtime/dist .
tar -czf "release-files/Oxbit_${version}_extensions.tar.gz" -C examples/bundle-inspector/dist .
node scripts/remote/release-manifest.mjs release-files apps/desktop/src-tauri/resources/runtime/remote
ls -l release-files
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: release-files/
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: remote-manifest
path: release-files/remote-runtime-manifest.json
if-no-files-found: error
# The pinned manifest records the darwin payload after the macos job signs and repacks it.
ios:
needs: [remote-runtime, macos]
runs-on: macos-26
timeout-minutes: 90
steps:
Expand Down Expand Up @@ -129,8 +137,13 @@ jobs:
- name: Check TestFlight testing notes
if: github.ref_type == 'tag'
run: node scripts/ios/distribute-testflight.mjs --build '${{ steps.version.outputs.build }}' --check-notes
- uses: actions/download-artifact@v4
with:
name: remote-manifest
path: .remote-manifest
- name: Signed App Store build
env:
TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST: ${{ github.workspace }}/.remote-manifest/remote-runtime-manifest.json
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class OxbitFilesPlugin: Plugin, UIDocumentPickerDelegate {
private let sshKeys = SshKeys()
private let filePicker = FilePicker()
private let signing = CommitSigning()
private let runtimeDownload = RuntimeDownload()
private var pending: Invoke?
private var open: [String: URL] = [:]

Expand Down Expand Up @@ -60,6 +61,14 @@ class OxbitFilesPlugin: Plugin, UIDocumentPickerDelegate {
sshKeys.handle(invoke)
}

@objc public func downloadRuntime(_ invoke: Invoke) {
runtimeDownload.handle(invoke)
}

@objc public func cancelRuntimeDownload(_ invoke: Invoke) {
runtimeDownload.cancel(invoke)
}

@objc public func pickFiles(_ invoke: Invoke) {
filePicker.pick(invoke, from: manager.viewController)
}
Expand Down
43 changes: 43 additions & 0 deletions apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownload.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation
import Tauri

private struct RuntimeDownloadArgs: Decodable {
let id: String
let url: String
let sha256: String
let size: Int64
}

private struct RuntimeDownloadCancelArgs: Decodable {
let id: String
}

/// Plugin dispatch for `RuntimeDownloadStore`, which saves the archive under Caches.
final class RuntimeDownload {
private let store = RuntimeDownloadStore(
directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("remote-runtime", isDirectory: true))

func handle(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(RuntimeDownloadArgs.self)
store.download(id: args.id, url: args.url, sha256: args.sha256, size: args.size) { result in
switch result {
case .success(let file): invoke.resolve(["path": file.path])
case .failure(let error): invoke.reject(error.message)
}
}
} catch {
invoke.reject(error.localizedDescription)
}
}

func cancel(_ invoke: Invoke) {
do {
store.cancel(id: try invoke.parseArgs(RuntimeDownloadCancelArgs.self).id)
invoke.resolve()
} catch {
invoke.reject(error.localizedDescription)
}
}
}
119 changes: 119 additions & 0 deletions apps/ios/plugins/oxbit-files/ios/Sources/RuntimeDownloadStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import CryptoKit
import Foundation

enum RuntimeDownloadError: Error, Equatable {
case invalid
case network(URLError.Code)
case status(Int)
case mismatch
case cancelled
case save(String)

var message: String {
switch self {
case .invalid: return "Invalid remote runtime download"
case .network, .status: return "Could not download the remote runtime. Check the network connection and retry."
case .mismatch: return "The remote runtime download did not match its pinned size and SHA-256."
case .cancelled: return "The remote runtime download was cancelled."
case .save(let reason): return "Could not save the remote runtime download: \(reason)"
}
}
}

/// Downloads the pinned remote runtime archive to `<directory>/<sha256>.tar.gz` and keeps it only
/// when its size and SHA-256 match. Each caller ID has at most one download, which `cancel` ends.
final class RuntimeDownloadStore {
let directory: URL
private let session: URLSession
private let lock = NSLock()
private var tasks: [String: URLSessionDownloadTask] = [:]

init(directory: URL, session: URLSession = RuntimeDownloadStore.defaultSession()) {
self.directory = directory
self.session = session
}

static func defaultSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.httpShouldSetCookies = false
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 600
return URLSession(configuration: config)
}

static func validated(url: String, sha256: String, size: Int64) -> URL? {
guard let parsed = URL(string: url), parsed.scheme == "https", parsed.host?.isEmpty == false,
sha256.count == 64, sha256.allSatisfy({ "0123456789abcdef".contains($0) }), size > 0
else { return nil }
return parsed
}

static func matches(_ file: URL, sha256: String, size: Int64) -> Bool {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: file.path),
(attributes[.size] as? NSNumber)?.int64Value == size,
let handle = try? FileHandle(forReadingFrom: file)
else { return false }
defer { try? handle.close() }
var hasher = SHA256()
while let chunk = try? handle.read(upToCount: 1 << 20), !chunk.isEmpty {
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined() == sha256
}

func download(id: String, url: String, sha256: String, size: Int64,
completion: @escaping (Result<URL, RuntimeDownloadError>) -> Void) {
guard let source = Self.validated(url: url, sha256: sha256, size: size) else {
return completion(.failure(.invalid))
}
let destination = directory.appendingPathComponent("\(sha256).tar.gz")
if Self.matches(destination, sha256: sha256, size: size) {
return completion(.success(destination))
}
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
} catch {
return completion(.failure(.save(error.localizedDescription)))
}
let token = UUID().uuidString
let task = session.downloadTask(with: source) { [weak self] location, response, error in
self?.forget(id, token: token)
if let error {
let code = (error as? URLError)?.code ?? .unknown
return completion(.failure(code == .cancelled ? .cancelled : .network(code)))
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard let location, status == 200 else { return completion(.failure(.status(status))) }
guard Self.matches(location, sha256: sha256, size: size) else {
try? FileManager.default.removeItem(at: location)
return completion(.failure(.mismatch))
}
do {
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: location, to: destination)
completion(.success(destination))
} catch {
completion(.failure(.save(error.localizedDescription)))
}
}
task.taskDescription = token
lock.lock()
let previous = tasks.updateValue(task, forKey: id)
lock.unlock()
previous?.cancel()
task.resume()
}

func cancel(id: String) {
lock.lock()
let task = tasks.removeValue(forKey: id)
lock.unlock()
task?.cancel()
}

private func forget(_ id: String, token: String) {
lock.lock()
if tasks[id]?.taskDescription == token { tasks[id] = nil }
lock.unlock()
}
}
83 changes: 83 additions & 0 deletions apps/ios/plugins/oxbit-files/ios/Tests/RuntimeDownload/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Foundation

/// Fixed GitHub release assets; release files do not change after publication.
let release = "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-apple-darwin.tar.gz"
let small = (url: release + ".sha256", size: Int64(108),
sha256: "a8c2b06a7490f4e5ed787973f6ab48d4dbbc73e2ab4e97a4a9e6094e46caaebf")
let large = (url: release, size: Int64(2_082_672),
sha256: "fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3")
let offline: Set<URLError.Code> = [.notConnectedToInternet, .cannotFindHost, .cannotConnectToHost,
.dnsLookupFailed, .timedOut, .networkConnectionLost, .dataNotAllowed, .internationalRoamingOff]

struct Failure: Error { let message: String }

func expect(_ value: Bool, _ message: String) throws {
guard value else { throw Failure(message: message) }
}

func download(_ store: RuntimeDownloadStore, id: String = "test", url: String, size: Int64,
sha256: String, then: (() -> Void)? = nil) throws -> Result<URL, RuntimeDownloadError> {
let done = DispatchSemaphore(value: 0)
let box = NSLock()
var result: Result<URL, RuntimeDownloadError>?
store.download(id: id, url: url, sha256: sha256, size: size) { value in
box.lock(); result = value; box.unlock()
done.signal()
}
then?()
guard done.wait(timeout: .now() + 120) == .success else { throw Failure(message: "\(url) timed out") }
box.lock(); defer { box.unlock() }
return result!
}

func runTests(_ store: RuntimeDownloadStore) throws {
let destination = store.directory.appendingPathComponent("\(small.sha256).tar.gz")
let first = try download(store, url: small.url, size: small.size, sha256: small.sha256)
if case .failure(.network(let code)) = first, offline.contains(code) {
print("RuntimeDownload tests skipped: the network is unavailable (URLError \(code.rawValue))")
exit(0)
}
try expect(first == .success(destination), "an HTTPS download with the pinned size and digest saves \(first)")
try expect(RuntimeDownloadStore.matches(destination, sha256: small.sha256, size: small.size),
"the saved file matches its digest")

try Data("corrupt".utf8).write(to: destination)
let healed = try download(store, url: small.url, size: small.size, sha256: small.sha256)
try expect(healed == .success(destination)
&& RuntimeDownloadStore.matches(destination, sha256: small.sha256, size: small.size),
"a cached file with the wrong digest downloads again")

let plain = small.url.replacingOccurrences(of: "https://", with: "http://")
try expect(try download(store, url: plain, size: small.size, sha256: small.sha256) == .failure(.invalid),
"http:// is refused")
try expect(try download(store, url: small.url, size: small.size, sha256: "ABC") == .failure(.invalid),
"a malformed digest is refused")

try FileManager.default.removeItem(at: destination)
try expect(try download(store, url: small.url, size: small.size - 1, sha256: small.sha256) == .failure(.mismatch),
"a size mismatch is refused")
let zeros = String(repeating: "0", count: 64)
try expect(try download(store, url: small.url, size: small.size, sha256: zeros) == .failure(.mismatch),
"a digest mismatch is refused")
try expect(try FileManager.default.contentsOfDirectory(atPath: store.directory.path).isEmpty,
"refused downloads leave nothing in the cache")

let cancelled = try download(store, id: "cancel", url: large.url, size: large.size, sha256: large.sha256) {
store.cancel(id: "cancel")
}
try expect(cancelled == .failure(.cancelled), "cancel ends the download: \(cancelled)")
try expect(!FileManager.default.fileExists(atPath: store.directory.appendingPathComponent("\(large.sha256).tar.gz").path),
"a cancelled download leaves no archive")
}

let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("oxbit-runtime-download-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: directory) }
do {
try runTests(RuntimeDownloadStore(directory: directory))
print("RuntimeDownload tests passed")
} catch {
FileHandle.standardError.write(Data("RuntimeDownload tests failed: \((error as? Failure)?.message ?? "\(error)")\n".utf8))
try? FileManager.default.removeItem(at: directory)
exit(1)
}
14 changes: 14 additions & 0 deletions apps/ios/plugins/oxbit-files/src/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ impl<R: Runtime> OxbitFiles<R> {
Err(UNSUPPORTED)
}

pub fn download_runtime(
&self,
_id: &str,
_url: &str,
_sha256: &str,
_size: u64,
) -> crate::Result<serde_json::Value> {
Err(UNSUPPORTED)
}

pub fn cancel_runtime_download(&self, _id: &str) -> crate::Result<()> {
Err(UNSUPPORTED)
}

pub fn pick_files(&self, _multiple: bool) -> crate::Result<PickedFiles> {
Err(UNSUPPORTED)
}
Expand Down
23 changes: 23 additions & 0 deletions apps/ios/plugins/oxbit-files/src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ impl<R: Runtime> OxbitFiles<R> {
.map_err(Into::into)
}

/// Internal only: the pinned remote runtime archive, downloaded with URLSession. `id` names
/// the download for `cancel_runtime_download`.
pub fn download_runtime(
&self,
id: &str,
url: &str,
sha256: &str,
size: u64,
) -> crate::Result<serde_json::Value> {
self.0
.run_mobile_plugin(
"downloadRuntime",
serde_json::json!({ "id": id, "url": url, "sha256": sha256, "size": size }),
)
.map_err(Into::into)
}

pub fn cancel_runtime_download(&self, id: &str) -> crate::Result<()> {
self.0
.run_mobile_plugin("cancelRuntimeDownload", serde_json::json!({ "id": id }))
.map_err(Into::into)
}

pub fn pick_files(&self, multiple: bool) -> crate::Result<PickedFiles> {
self.0
.run_mobile_plugin("pickFiles", serde_json::json!({ "multiple": multiple }))
Expand Down
24 changes: 24 additions & 0 deletions apps/ios/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
use std::{env, fs, path::PathBuf};

// `tauri ios build` runs cargo from the Xcode script phase with a filtered
// environment that keeps TAURI_ variables and drops OXBIT_ ones.
const MANIFEST_VARIABLE: &str = "TAURI_OXBIT_REMOTE_RUNTIME_MANIFEST";

fn main() {
println!("cargo:rerun-if-env-changed={MANIFEST_VARIABLE}");
let pinned = match env::var_os(MANIFEST_VARIABLE).filter(|value| !value.is_empty()) {
Some(file) => {
let file = PathBuf::from(file);
println!("cargo:rerun-if-changed={}", file.display());
let text = fs::read_to_string(&file).unwrap_or_else(|error| {
panic!("{MANIFEST_VARIABLE} ({}): {error}", file.display())
});
format!("Some({text:?})")
}
None => "None".to_owned(),
};
let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo"));
fs::write(
output.join("remote_runtime_manifest.rs"),
format!("const PINNED_MANIFEST: Option<&str> = {pinned};\n"),
)
.expect("write the pinned remote runtime manifest");
tauri_build::build();
}
Loading
Loading