Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 55 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
# would pressure people into editing code the repo deliberately keeps as
# upstream's.
#
# Native code is not compiled here: these are watchOS FFI plugins, and every
# test fakes the bindings through a `forTesting` constructor, so nothing needs
# a Mac. Compiling the `.m` sources needs a watchOS SDK and belongs in the
# per-package `example` build, run on a Mac by hand — see AUTHORING.md §3.
# The package tests fake the native bindings through a `forTesting`
# constructor, so they need no Mac. The `native` job compiles the native
# sources on macOS with -fsyntax-only, the way the CLI builds them, on Xcode 26
# and 27; running the code is still each package's `example` build on a Mac
# (AUTHORING.md §3).
name: CI

on:
Expand Down Expand Up @@ -187,3 +188,53 @@ jobs:
if: success() || failure()
working-directory: tool
run: dart run bin/check_test_names.dart

native:
# Spec 0022 D1 (#17): every plugin's .m, .mm, .c and SwiftUI view source,
# compiled the way the CLI compiles it, with -fsyntax-only, for a watch
# and for the Simulator. macos-26 runs the oldest Xcode 26 the CLI
# supports and its default; xcode-27 runs Xcode 27. A symbol one SDK lacks
# (CLActivityTypeMaritime in #12) fails here, not in an app's build.
name: native sources (${{ matrix.label }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- label: Xcode 26
runner: macos-26
developer_dirs: >-
/Applications/Xcode_26.0.1.app/Contents/Developer
/Applications/Xcode_26.6.app/Contents/Developer
- label: Xcode 27
runner: xcode-27
developer_dirs: /Applications/Xcode_27.app/Contents/Developer
steps:
- uses: actions/checkout@v4

- name: Compile with each Xcode
env:
DEVELOPER_DIRS: ${{ matrix.developer_dirs }}
run: |
status=0
for developer_dir in $DEVELOPER_DIRS; do
if [ ! -d "$developer_dir" ]; then
echo "FAILED no Xcode at $developer_dir"
status=1
continue
fi
DEVELOPER_DIR="$developer_dir" tool/check_native_sources.sh || status=1
done
exit "$status"

# The check must be able to fail. Its fixtures hold an undeclared
# symbol and a warning, which fail, and a clean source with a
# deprecation, which passes.
- uses: dart-lang/setup-dart@v1

- name: Test the check
working-directory: tool
run: |
dart pub get
dart test test/check_native_sources_test.dart
23 changes: 23 additions & 0 deletions tool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ tool/check_generated_ignored.sh
(cd tool && dart run bin/publish_dry_run.dart)
```

The `native` job of `ci.yml` runs one more on macOS, once per Xcode, as
`DEVELOPER_DIR` picks it. It needs Xcode, so the Ubuntu jobs leave it out:

```sh
tool/check_native_sources.sh
```

The weekly workflow `.github/workflows/upstream.yml` runs two more, which
read pub.dev and so are not part of the per-change checks:

Expand Down Expand Up @@ -108,6 +115,22 @@ runs it in the package job, after the tests. Its report goes into a public
log, so a line of pub's output that holds a forbidden word is withheld and
fails the package.

## Native sources

`check_native_sources.sh` compiles every plugin's native watchOS sources the way
the CLI does, with `-fsyntax-only`, for `arm64-apple-watchos26.0` and its
Simulator triple:
- `.m`, `.mm` and `.c` files go through clang, with the CLI's flags;
- SwiftUI view sources go through `swiftc -typecheck`, with a copy of the
CLI's registration shim (`native/plugin_views_shim.swift`).

An error or a warning fails, except a deprecation, which is counted.
in_app_purchase_watchos uses StoreKit 1 on purpose. A package whose
`watchos/Package.swift` names a SwiftPM package is listed and skipped, as only
a SwiftPM build provides its modules. `--list` prints the plan without
compiling, and the Ubuntu tool tests run it on fixtures. The compile fixtures
run on macOS, in the `native` job.

## Weekly upstream compatibility

`.github/workflows/upstream.yml` runs every Monday and by hand, and never
Expand Down
159 changes: 159 additions & 0 deletions tool/check_native_sources.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# Copyright 2026 The FlutterWatch Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
#
# Compiles every plugin's native watchOS sources the way flutter-watchos does,
# with -fsyntax-only, for a watch and for the Simulator, with the Xcode that
# DEVELOPER_DIR (or xcode-select) picks. CI runs it with the oldest Xcode 26
# the CLI supports, the newest Xcode 26 and Xcode 27, so a symbol that one
# SDK lacks fails here and not in an app's build (spec 0022 D1, #17).
#
# - Objective-C and C sources (.m, .mm, .c) go through clang with the CLI's
# flags (lib/build_targets/application.dart in flutter-watchos 0.1.1).
# - SwiftUI view sources (.swift under watchos/, except Package.swift and
# anything under Flutter/ or a dot-directory) go through swiftc -typecheck,
# one module per plugin, with the CLI's registration shim
# (tool/native/plugin_views_shim.swift).
# - A plugin whose watchos/Package.swift names a SwiftPM package is skipped
# and listed: its sources import that package's modules, which only a
# SwiftPM build provides, and the CLI builds such a plugin with xcodebuild.
#
# Only git-tracked files are read. A warning fails like an error, as in the
# CLI's own host typecheck, except a deprecation: those are counted and left
# to the package (in_app_purchase_watchos uses StoreKit 1 on purpose).
#
# Usage: tool/check_native_sources.sh [--root <repository>] [--list]
# --list prints what would be compiled and runs nothing; no Xcode needed.
set -euo pipefail

# The deployment target `flutter-watchos create` writes.
readonly deployment_target="26.0"
readonly targets="watchos:arm64-apple-watchos${deployment_target} watchsimulator:arm64-apple-watchos${deployment_target}-simulator"

root=""
list=0
while [ $# -gt 0 ]; do
case "$1" in
--root) [ $# -ge 2 ] || { echo "--root needs a value" >&2; exit 2; }; root="$2"; shift 2 ;;
--list) list=1; shift ;;
*) echo "usage: $0 [--root <repository>] [--list]" >&2; exit 2 ;;
esac
done
if [ -z "$root" ]; then
root="$(git rev-parse --show-toplevel)"
fi
shim="$(cd "$(dirname "$0")" && pwd)/native/plugin_views_shim.swift"
cd "$root"

# The tracked files under each package's own watchos/ directory. In a
# pathspec `*` also crosses `/`, so the pattern alone would take in each
# example's watchos/ app too; the grep keeps the package's.
tracked="$(git ls-files -- 'packages/*/watchos/*' | grep -E '^packages/[^/]+/watchos/' || true)"

# Every directory that holds a tracked header: the CLI passes each one to
# every plugin's compile.
includes=()
for dir in $(printf '%s\n' "$tracked" | grep '\.h$' | xargs -n1 dirname | sort -u); do
includes+=("-I$dir")
done

# True for a view source: not the manifest, and nothing under Flutter/ or a
# dot-directory (CLI build output and SwiftPM state).
is_view_source() {
local relative="${1#*/watchos/}"
case "$relative" in
Package.swift | Flutter/* | */Flutter/* | .* | */.*) return 1 ;;
*.swift) return 0 ;;
*) return 1 ;;
esac
}

# Prints the warnings in [1] that are not deprecations.
other_warnings() {
grep 'warning:' <<< "$1" | grep -v -e 'is deprecated' -e 'was deprecated' || true
}

# Reports one compile: [1] its name, [2] its exit code, [3] its output.
report() {
local deprecations
deprecations="$(grep -c 'warning:.*deprecated' <<< "$3" || true)"
if [ "$2" -eq 0 ] && [ -z "$(other_warnings "$3")" ]; then
if [ "$deprecations" -gt 0 ]; then
echo "ok $xcode $triple $1 ($deprecations deprecation warning$([ "$deprecations" -eq 1 ] || echo s))"
else
echo "ok $xcode $triple $1"
fi
else
echo "FAILED $xcode $triple $1"
echo "$3"
status=1
fi
}

xcode=""
if [ "$list" -eq 0 ]; then
# sed, not head: head would close the pipe early, and pipefail would end
# the script on xcodebuild's SIGPIPE.
xcode="$(xcodebuild -version | sed -n 1p)"
fi

status=0
checked=0
for package_dir in $(printf '%s\n' "$tracked" | cut -d/ -f1-2 | sort -u); do
package="${package_dir#packages/}"
manifest="$package_dir/watchos/Package.swift"
if [ -f "$manifest" ] && grep -q '\.package(' "$manifest"; then
echo "skip $package: its watchos/Package.swift names a SwiftPM package, which only a SwiftPM build provides"
continue
fi
c_sources=()
view_sources=()
for file in $(printf '%s\n' "$tracked" | grep "^$package_dir/"); do
case "$file" in
*.m | *.mm | *.c) c_sources+=("$file") ;;
*) if is_view_source "$file"; then view_sources+=("$file"); fi ;;
esac
done
for target in $targets; do
sdk="${target%%:*}"
triple="${target#*:}"
for source in ${c_sources[@]+"${c_sources[@]}"}; do
checked=$((checked + 1))
if [ "$list" -eq 1 ]; then
echo "plan clang $sdk $triple $source"
continue
fi
code=0
output="$(xcrun -sdk "$sdk" clang -fsyntax-only -target "$triple" \
-fobjc-arc -fmodules ${includes[@]+"${includes[@]}"} "$source" 2>&1)" || code=$?
report "$source" "$code" "$output"
done
if [ ${#view_sources[@]} -gt 0 ]; then
checked=$((checked + 1))
if [ "$list" -eq 1 ]; then
echo "plan swiftc $sdk $triple ${package}_views: ${view_sources[*]}"
continue
fi
code=0
output="$(xcrun -sdk "$sdk" swiftc -typecheck -target "$triple" \
-parse-as-library -module-name "${package}_views" \
"$shim" "${view_sources[@]}" 2>&1)" || code=$?
report "${package}_views" "$code" "$output"
fi
done
done

if [ "$checked" -eq 0 ]; then
echo "No native source found under packages/*/watchos/."
exit 1
fi
if [ "$list" -eq 1 ]; then
echo "include ${includes[*]+"${includes[*]}"}"
exit 0
fi
if [ "$status" -ne 0 ]; then
echo "Native sources: some do not compile cleanly with $xcode."
exit 1
fi
echo "Native sources: all compile cleanly with $xcode."
79 changes: 79 additions & 0 deletions tool/native/plugin_views_shim.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// A copy of the registration shim flutter-watchos compiles into every plugin
// module with SwiftUI view sources (kPluginViewSupportSwift in the CLI's
// lib/build_targets/watchos_plugin_views.dart, CLI 0.1.1). It is here only so
// tool/check_native_sources.sh can type-check plugin view sources the way the
// CLI compiles them. Update it with the CLI. Not part of any plugin.
//
// Compiled by flutter-watchos into every plugin module that ships SwiftUI
// view sources. Not part of the plugin — do not copy it into one.
//
// Gives plugin code `FlutterWatchOSPluginViews.register(_:factory:)`, the
// Swift face of the app runner's C registration entry point.

import Darwin
import Foundation
import SwiftUI

private let _factoriesLock = NSLock()
private var _factories: [String: (String) -> AnyView] = [:]

/// The @convention(c) trampoline handed to the runner, once per registered
/// type. Receives (viewType, creationParams); returns the built AnyView
/// RETAINED, boxed by the Swift runtime (`as AnyObject` — SwiftUI hard-rejects
/// class-conforming Views, so the view itself must stay a struct and the
/// runtime box is the only class in the crossing). The runner unwraps it with
/// a plain dynamic cast, since AnyView is SwiftUI's own type on both sides.
/// nil means no factory — render nothing.
private let _factoryTrampoline: @convention(c) (
UnsafePointer<CChar>?, UnsafePointer<CChar>?
) -> UnsafeMutableRawPointer? = { type, params in
guard let type else { return nil }
let viewType = String(cString: type)
let creationParams = params.map { String(cString: $0) } ?? ""
_factoriesLock.lock()
let factory = _factories[viewType]
_factoriesLock.unlock()
guard let factory else { return nil }
return Unmanaged.passRetained(factory(creationParams) as AnyObject).toOpaque()
}

/// Registration surface for a plugin's native SwiftUI views.
public enum FlutterWatchOSPluginViews {
private typealias _Register = @convention(c) (
UnsafePointer<CChar>?,
(@convention(c) (
UnsafePointer<CChar>?, UnsafePointer<CChar>?
) -> UnsafeMutableRawPointer?)?
) -> Void

/// The runner's registration entry point, resolved via dlsym
/// (RTLD_DEFAULT is the special -2 handle) so nothing links against the
/// app at compile time. nil when the app was created by a CLI that
/// predates plugin platform views.
private static let _register: _Register? = {
guard let sym = dlsym(
UnsafeMutableRawPointer(bitPattern: -2),
"FlutterWatchOSPlatformViewRegisterNativeFactory")
else { return nil }
return unsafeBitCast(sym, to: _Register.self)
}()

/// Registers (or replaces) the native factory for `viewType`. The factory
/// receives the Dart widget's `creationParams` string and always runs on
/// the main thread; `register` itself may be called from any thread
/// (plugin registration typically arrives over FFI from Dart).
public static func register(
_ viewType: String, factory: @escaping (String) -> AnyView
) {
_factoriesLock.lock()
_factories[viewType] = factory
_factoriesLock.unlock()
guard let register = _register else {
NSLog("FlutterWatchOSPluginViews: this app's runner predates "
+ "plugin platform views; '\(viewType)' will not render. "
+ "Re-create the app with a current flutter-watchos.")
return
}
viewType.withCString { register($0, _factoryTrampoline) }
}
}
Loading
Loading