skip icon indicating copy to clipboard operation
skip copied to clipboard

Document what SKIP_BRIDGE does

Open dfabulich opened this issue 2 months ago • 5 comments

SKIP_BRIDGE only appears once in Skip's documentation, on https://skip.tools/docs/modes/ and it is never defined. It simply says to use this boilerplate at the top of transpiled files.

#if !SKIP_BRIDGE
#if os(Android)

But the documentation never explains what SKIP_BRIDGE means/does. The only other remark about SKIP_BRIDGE is a warning:

When bridging is enabled on a transpiled module, enclose all code in #if !SKIP_BRIDGE to avoid duplicate symbol errors, as in our example above. The SkipStone build plugin will warn you if this is missing.

But, like, what does SKIP_BRIDGE even do? I think it's something like "skip android build will natively compile Skip code with SKIP_BRIDGE set to true, and then transpile code with SKIP_BRIDGE=0." Is thaht right?

But why do we even need it? Isn't that the whole point of #if SKIP in native Fuse mode?

Fuse users have to learn the difference between #if os(Android) and #if SKIP as it is. #if SKIP_BRIDGE=0 is at least very close to #if SKIP, and if it's subtly different, and if I nonetheless need to use it sometimes, those differences should be clearly documented here.

dfabulich avatar Oct 08 '25 19:10 dfabulich

When building a bridgeable transpiled Skip module in SkipFuse mode, the Gradle file generated by SkipBridge calls skip gradle build -DSKIP_BRIDGE package-name. The Skip plugin sees this and creates Kotlin JNI bindings for all the bridgeable types and functions, which replaces the Swift implementation with a version whose implementation calls through to the Kotlin implementation.

For example, take this simplified form of SkipKeychain.swift, which is part of a transpiled and bridged module:

#if !SKIP_BRIDGE
import Foundation
#if SKIP
import android.content.Context
#endif

public struct Keychain {
    public func string(forKey key: String) throws -> String? {
        #if SKIP
        // transpiled Kotlin: do stuff with androidx.security.crypto.EncryptedSharedPreferences
        #else
        // native iOS build: do something with the iOS Keychain
        #endif
    }
}
#endif // !SKIP_BRIDGE

When building for Android, the skipstone plugin will generate a SkipBridgeGenerated/SkipKeychain_Bridged.swift file, that looks something like this:

import SkipBridge
import Foundation

public struct Keychain: BridgedFromKotlin {
    nonisolated private static let Java_class = try! JClass(name: "skip/keychain/Keychain")
    nonisolated public var Java_peer: JObject

    public func string(forKey p_0: String) throws -> String? {
        return try jniContext {
            let p_0_java = p_0.toJavaParameter(options: [])
            do {
                let f_return_java: JavaObjectPointer? = try Java_peer.call(method: Self.Java_string_0_methodID, options: [], args: [p_0_java])
                return String?.fromJavaObject(f_return_java, options: [])
            } catch let error as (Error & JConvertible) {
                throw error
            } catch {
                fatalError(String(describing: error))
            }
        }
    }
}

The reason we need the SKIP_BRIDGE constant is that there isn't any way to tell the Swift package to avoid compiling the original SkipKeychain.swift in lieu of the bridged API that is created in SkipKeychain_Bridged.swift. So we need to have the manual step of essentially saying: here is the API surface for skipstone to analyze and bridge, but don't actually build this version when compiling for Android (both because there would be symbol clashes, and because the actual compiled implementation depends on iOS frameworks that do not exist on Android).

You are absolutely right that this is all woefully under-documented. It is "advanced-use", because it is only needed when creating a transpiled module that is bridged to native for use from SkipFuse. I'll use this response as an outline for adding it to our docs. Thanks!

Image

marcprux avatar Oct 08 '25 20:10 marcprux

I still don't quite get it.

The reason we need the SKIP_BRIDGE constant is that there isn't any way to tell the Swift package to avoid compiling the original SkipKeychain.swift in lieu of the bridged API that is created in SkipKeychain_Bridged.swift.

But, there is a way… you could just write #if SKIP around your code, right? That signals to a Fuse app that the code inside should be transpiled and automatically bridged, and, more to the point, it tells swift build not to compile that stuff on an initial run, because the SKIP environment variable isn't set.

It sounds like skip android build does something completely different depending on whether you're building a a native library (that just happens to be full of transpiled bridged code) vs. building a transpiled bridged library, but I don't think I understand why they're different.

Here's another question that might help me understand.

In a transpiled library with bridging, what happens to code outside #if !SKIP_BRIDGE block? There are no examples of this in SkipKeychain, which has only one Swift file, and the whole file is in #if !SKIP_BRIDGE.

Is it simply an error to have any code outside #if !SKIP_BRIDGE in a transpiled library with bridging enabled?

I think the answer is yes, and that's because in a transpiled Lite library with bridging enabled, swift build will always compile the Swift files directly with no transformations of any kind. If there's any Swift outside #if !SKIP_BRIDGE, then Skip Fuse will try to compile it natively, and then Skip Lite will try to transpile + bridge it. The build is certain to fail as a result.

But, then, why isn't #if !SKIP_BRIDGE required in Fuse apps?

dfabulich avatar Oct 09 '25 00:10 dfabulich

There are three valid combinations of these variables:

  1. !SKIP && !SKIP_BRIDGE: e.g., iOS or macOS. Skip doesn't do anything here, and the code just runs on iOS directly.
  2. SKIP && !SKIP_BRIDGE: Code in these blocks is transpiled into Kotlin, both for Skip Lite apps and for the Kotlin side of a Skip Fuse app
  3. SKIP_BRIDGE: Native compilation on Android. Nothing in this file is compiled when building for Android. Rather, the contents of the file are analyzed in order to generate the native JNI bridging that calls into the transpiled code in 2.

So 2 and 3 exist side-by-side in a Skip Fuse app, where the compiled bridged code created in 3 is calling into the transpiled Kotlin code created in 2.

It sounds like skip android build does something completely different depending on whether you're building a a native library (that just happens to be full of transpiled bridged code) vs. building a transpiled bridged library, but I don't think I understand why they're different.

Yes, there are two phases when building a SkipFuse app:

  1. At build time in Xcode (or from the CLI), the skipstone package is run as usual. As well as transpiling all the code (except in !SKIP blocks), it also generates bridging Swift that will call between the compiled and transpiled worlds.
  2. At app packaging time, the skip android build process compiles the aforementioned bridging Swift natively for Android.

But, then, why isn't #if !SKIP_BRIDGE required in Fuse apps?

Because the code in there isn't transpiled. It is calling into the native SkipFuseUI module, which itself is bridged to the transpiled SkipUI module. It is somewhat unique, due to the complexity of SwiftUI. Most other modules like SkipBluetooth and SkipFirebase are transpiled and bridged, and dependent SkipFuse apps (and modules) just call the bridged-generated API directly for those modules.

marcprux avatar Oct 09 '25 01:10 marcprux

I've read and reread this message a few times over the last few days and I can't make heads or tails of it.

In particular, I must be misunderstanding you, but it seems like you've contradicted yourself. You write that in the first phase of a SkipFuse app, it transpiles all of the code (except in !SKIP blocks), but then, only a paragraph later, you write that !SKIP_BRIDGE isn't required in Fuse apps because the code in there isn't transpiled.

Is that a contradiction?

Let me try answering my Oct 8 question in a way that makes sense to me; please confirm whether it's accurate.

Today, you could write a "transpiled/bridged" library entirely in Fuse, entirely in #if SKIP blocks (such that all of the code would be transpiled and bridged). But that would suck in one important way: since all of the code would be in #if SKIP blocks, none of the code would be visible to regular iOS Swift code. You could workaround that by copying and pasting every file, once in #if SKIP and then again in #if !SKIP else blocks, but that would be very duplicative and uncomfortable.

So, instead, you'd want to write that library in a mode where all the code gets transpiled and bridged by default. But then you'd need the native compiler to compile only the generated bridges and not your original Swift code.

Ideally, that's what would would automatically happen when you set mode: transpiled with bridging: true, but there's a usability bug in the native Android compiler: by default, it's gonna try to compile both the bridges and all of your library code.

There's an easy workaround for that bug: in transpiled + bridged mode, you just have to wrap all of your code in !SKIP_BRIDGE blocks. There's no principled reason why the code must require !SKIP_BRIDGE; y'all could fix it, but your time is limited, transpiled+bridged libraries are rare, and the workaround works.

Am I on the right track?

dfabulich avatar Oct 13 '25 00:10 dfabulich

That's about right, except:

There's no principled reason why the code must require !SKIP_BRIDGE; y'all could fix it, but your time is limited, transpiled+bridged libraries are rare, and the workaround works.

The fix would need to come from the Swift Package Manager's build plugin via an enhancement. There isn't any way for a build plugin to cause code to be excluded from being compiled, so in order to present the same API surface to native code on Android as on iOS, we need to somehow replace the code with the plugin-generated version, which isn't currently possible with the Swift build plugin model.

As a concrete example, imaging the following Skip-compatible bridged code RandomGenerator.swift:

#if !SKIP_BRIDGE
public func randomNumber(upperLimit: Int) -> Int {
    #if SKIP
    return java.util.Random().nextInt(upperLimit)
    #else
    return Swift.Int.random(in: 0..<upperLimit)
    #endif
}
#endif

A. On iOS, this simply distills down to RandomGenerator.swift:

public func randomNumber(upperLimit: Int) -> Int {
    return Swift.Int.random(in: 0..<upperLimit)
}

B. In transpiled Skip Lite RandomGenerator.kt:

public fun randomNumber(upperLimit: Int): Int {
   return java.util.Random().nextInt(upperLimit)
}

C. In native Skip Fuse for Android, RandomGenerator_Bridge.swift:

public func randomNumber(upperLimit: Int) -> Int {
    return someJNIStuffThatCallsTheTranspiledVersionInB()
}

But there isn't any way to prevent C's RandomGenerator_Bridge.swift from co-existing with the original source RandomGenerator.swift, which would lead to a symbol clash for the randomNumber function. That's why we block everything inside SKIP_BRIDGE: it is what lets us elide the original code and just use the bridged version when building natively for Android (which is when we pass in the -DSKIP_BRIDGE compiler flag).

marcprux avatar Oct 13 '25 16:10 marcprux