├── .github └── workflows │ ├── build.yml │ └── deploy.yml ├── .gitignore ├── .swift-version ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── GitHubExample │ ├── GitHubAPI.swift │ ├── GitHubExample.swift │ ├── Pagination.swift │ └── Repository.swift ├── GitHubExampleWeb │ ├── GitHubView.swift │ ├── GitHubViewController.swift │ ├── Resource.swift │ ├── WebBindings │ │ ├── WebDocument.swift │ │ ├── WebFetch.swift │ │ ├── WebJSON.swift │ │ └── WebUtils.swift │ ├── WebFetchSession.swift │ └── main.swift └── GitHubExampleiOS │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ └── LaunchScreen.storyboard │ ├── Components │ ├── RemoteImage.swift │ └── SearchBar.swift │ ├── ContentView.swift │ ├── Info.plist │ ├── NativeNetworkSession.swift │ └── SceneDelegate.swift ├── Tests ├── GitHubExampleTests │ └── GitHubExampleTests.swift └── LinuxMain.swift ├── js └── index.js ├── package-lock.json ├── package.json ├── project.yml ├── static ├── index.html └── style.css ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: [main] 6 | jobs: 7 | test_on_wasm: 8 | name: Build and Test on WebAssembly 9 | runs-on: ubuntu-20.04 10 | container: 11 | image: ghcr.io/swiftwasm/carton:latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - run: carton test 15 | test_on_native: 16 | name: Build and Test on Native 17 | runs-on: macos-11 18 | steps: 19 | - uses: actions/checkout@v2 20 | - run: swift test 21 | build: 22 | name: Build and Upload artifact 23 | runs-on: ubuntu-20.04 24 | container: 25 | image: ghcr.io/swiftwasm/swift:5.3 26 | steps: 27 | - uses: actions/checkout@v2 28 | - run: apt update && apt install nodejs npm -y 29 | - name: Build 30 | run: npm install && npm run build:prod 31 | - uses: actions/upload-artifact@v2 32 | with: 33 | name: dist 34 | path: dist 35 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: [main] 5 | jobs: 6 | deploy: 7 | name: Build and Upload artifact 8 | runs-on: Ubuntu-18.04 9 | container: 10 | image: ghcr.io/swiftwasm/swift:5.3 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: apt update && apt install nodejs npm -y 14 | - name: Build 15 | run: npm install && npm run build:prod 16 | - name: Deploy 17 | uses: peaceiris/actions-gh-pages@v3 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | publish_dir: ./dist 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .DS_Store 4 | /.build 5 | /Packages 6 | /*.xcodeproj 7 | xcuserdata/ 8 | .netlify 9 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | wasm-5.4.0-RELEASE 2 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "JavaScriptKit", 6 | "repositoryURL": "https://github.com/swiftwasm/JavaScriptKit.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "8ba4135d5fd6a734c3771ef3fac66896bbcb0214", 10 | "version": "0.8.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "GitHubExample", 7 | products: [ 8 | .library(name: "GitHubExample", 9 | targets: ["GitHubExample"]) 10 | ], 11 | dependencies: [ 12 | .package(name: "JavaScriptKit", url: "https://github.com/swiftwasm/JavaScriptKit.git", from: "0.8.0"), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "GitHubExampleWeb", 17 | dependencies: ["GitHubExample", "JavaScriptKit"]), 18 | .target(name: "GitHubExample", dependencies: []), 19 | .testTarget(name: "GitHubExampleTests", dependencies: [ 20 | "GitHubExample" 21 | ]) 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Search 🔍 2 | 3 | Demo project of Swift on Web App 4 | 5 | https://swiftwasm.github.io/swift-web-github-example/ 6 | 7 | 8 | 9 | ## Requirements 10 | 11 | This project only supports [`swiftwasm/swift`](https://github.com/swiftwasm/swift) distribution toolchain. 12 | Please install Swift for WebAssembly toolchain using [Installation instruction](https://book.swiftwasm.org/getting-started/setup.html) 13 | 14 | ## Getting Started 15 | 16 | ```sh 17 | npm install 18 | npm run start 19 | ``` 20 | 21 | ## Testing 22 | 23 | ```sh 24 | swift test 25 | ``` 26 | 27 | ## iOS App 28 | 29 | [XcodeGen](https://github.com/yonaskolb/XcodeGen/) is required to make `.xcodeproj` 30 | 31 | ```sh 32 | $ xcodegen 33 | $ open GitHubExampleiOS.xcodeproj 34 | ``` 35 | 36 | 37 | ## Development Tips 38 | 39 | You can edit source code and run test case on Xcode 40 | 41 | ```sh 42 | swift package generate-xcodeproj 43 | ``` 44 | -------------------------------------------------------------------------------- /Sources/GitHubExample/GitHubAPI.swift: -------------------------------------------------------------------------------- 1 | public protocol Task: AnyObject {} 2 | public protocol NetworkSession { 3 | func get(_ request: R, _ callback: @escaping (Result) -> Void) -> Task 4 | } 5 | 6 | public protocol GitHubAPIRequest { 7 | associatedtype Response: Decodable 8 | var baseURL: String { get } 9 | var path: String { get } 10 | var queryParameters: [String: String] { get } 11 | } 12 | 13 | extension GitHubAPIRequest { 14 | public var baseURL: String { 15 | "https://api.github.com/" 16 | } 17 | public var queryParameters: [String: String] { return [:] } 18 | } 19 | 20 | public struct GitHubSearchRepositoryRequest: GitHubAPIRequest { 21 | public struct Response: Codable { 22 | let items: [Repository] 23 | public init(items: [Repository]) { 24 | self.items = items 25 | } 26 | } 27 | 28 | public var path: String { "search/repositories" } 29 | public let queryParameters: [String: String] 30 | 31 | let query: String 32 | let page: Int 33 | 34 | public init(query: String, page: Int) { 35 | self.queryParameters = ["q": query, "page": page.description] 36 | self.query = query 37 | self.page = page 38 | } 39 | } 40 | 41 | extension GitHubSearchRepositoryRequest: PaginationRequest { 42 | func next(from response: Response) -> GitHubSearchRepositoryRequest? { 43 | return response.items.isEmpty ? nil : GitHubSearchRepositoryRequest(query: query, page: page + 1) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/GitHubExample/GitHubExample.swift: -------------------------------------------------------------------------------- 1 | public class GitHubExampleApp { 2 | 3 | public enum Event { 4 | case repositories([Repository]) 5 | case error(Error) 6 | } 7 | 8 | var pagination: Pagination 9 | let networkSession: NetworkSession 10 | var handlers: [(Event) -> Void] = [] 11 | 12 | var repos: [Repository] = [] 13 | 14 | public init(networkSession: NetworkSession) { 15 | self.pagination = Pagination(networkSession: networkSession) 16 | self.networkSession = networkSession 17 | 18 | pagination.subscribe { [weak self] result in 19 | guard let self = self else { return } 20 | let event: Event 21 | switch result { 22 | case .initial(let response): 23 | self.repos = response.items 24 | event = .repositories(self.repos) 25 | case .next(let response): 26 | self.repos += response.items 27 | event = .repositories(self.repos) 28 | case .error(let error): 29 | event = .error(error) 30 | } 31 | self.handlers.forEach { 32 | $0(event) 33 | } 34 | } 35 | } 36 | 37 | public func subscribe(_ handler: @escaping (Event) -> Void) { 38 | handlers.append(handler) 39 | } 40 | 41 | public func search(query: String) { 42 | let request = GitHubSearchRepositoryRequest(query: query, page: 1) 43 | self.pagination.initialize(request) 44 | } 45 | 46 | public func nextPage() { 47 | self.pagination.next() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/GitHubExample/Pagination.swift: -------------------------------------------------------------------------------- 1 | protocol PaginationRequest: GitHubAPIRequest { 2 | func next(from response: Response) -> Self? 3 | } 4 | 5 | class Pagination { 6 | 7 | enum Event { 8 | case initial(R.Response) 9 | case next(R.Response) 10 | case error(Error) 11 | } 12 | 13 | private(set) var isInitial = true 14 | private(set) var isLoading = false 15 | private(set) var isFinished = false 16 | 17 | private var currentRequest: R? 18 | private let networkSession: NetworkSession 19 | private var subscribers: [(Event) -> Void] = [] 20 | private var task: Task? 21 | 22 | init(networkSession: NetworkSession) { 23 | self.networkSession = networkSession 24 | } 25 | 26 | func subscribe(_ subscriber: @escaping (Event) -> Void) { 27 | subscribers.append(subscriber) 28 | } 29 | 30 | private func notify(_ event: Event) { 31 | subscribers.forEach { $0(event) } 32 | } 33 | 34 | func initialize(_ request: R) { 35 | currentRequest = request 36 | isInitial = true 37 | isFinished = false 38 | next() 39 | } 40 | 41 | func next() { 42 | guard let currentRequest = currentRequest, !isLoading && !isFinished else { 43 | return 44 | } 45 | isLoading = true 46 | task = networkSession.get(currentRequest) { [weak self] result in 47 | guard let self = self else { return } 48 | switch result { 49 | case .success(let response): 50 | if self.isInitial { 51 | self.isInitial = false 52 | self.notify(.initial(response)) 53 | } else { 54 | self.notify(.next(response)) 55 | } 56 | self.isLoading = false 57 | guard let nextRequest = currentRequest.next(from: response) else { 58 | self.isFinished = true 59 | return 60 | } 61 | self.currentRequest = nextRequest 62 | case .failure(let error): 63 | self.notify(.error(error)) 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/GitHubExample/Repository.swift: -------------------------------------------------------------------------------- 1 | public struct Repository: Codable { 2 | public let id: Int 3 | public let fullName: String 4 | public let description: String? 5 | public let htmlURL: String 6 | public let owner: User 7 | 8 | enum CodingKeys: String, CodingKey { 9 | case id 10 | case fullName = "full_name" 11 | case description 12 | case htmlURL = "html_url" 13 | case owner 14 | } 15 | } 16 | 17 | public struct User: Codable { 18 | public let avatarURL: String 19 | 20 | enum CodingKeys: String, CodingKey { 21 | case avatarURL = "avatar_url" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/GitHubExampleWeb/GitHubView.swift: -------------------------------------------------------------------------------- 1 | import GitHubExample 2 | 3 | struct GitHubView { 4 | let body: WebDocumentObject 5 | let searchForm: WebDocumentObject 6 | let repositoryList: WebDocumentObject 7 | let loadMoreTag: WebDocumentObject 8 | 9 | func queryString() -> String { 10 | searchForm.query.object!.value.string! 11 | } 12 | 13 | func setRepositories(_ repos: [Repository]) { 14 | let innerHtml = repos.map { 15 | repositoryView(repo: $0) 16 | }.joined() 17 | 18 | view.repositoryList.innerHTML = innerHtml 19 | } 20 | 21 | func repositoryView(repo: Repository) -> String { 22 | """ 23 | 24 | 25 | 26 | 27 | \(repo.fullName) 28 | 29 | \(repo.description ?? "") 30 | 31 | 32 | """ 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/GitHubExampleWeb/GitHubViewController.swift: -------------------------------------------------------------------------------- 1 | import GitHubExample 2 | import JavaScriptKit 3 | 4 | class GitHubViewController { 5 | 6 | let view: GitHubView 7 | let observer: WebIntersectionObserver 8 | 9 | init( 10 | view: GitHubView, 11 | app: GitHubExampleApp 12 | ) { 13 | self.view = view 14 | self.observer = WebIntersectionObserver { entries in 15 | guard let entry = entries.first, entry.isIntersecting else { 16 | return 17 | } 18 | app.nextPage() 19 | } 20 | 21 | self.observer.observe(view.loadMoreTag) 22 | 23 | view.searchForm.addEventListener("submit") { event in 24 | event.preventDefault() 25 | let query = view.queryString() 26 | app.search(query: query) 27 | } 28 | 29 | app.subscribe { (event) in 30 | switch event { 31 | case .repositories(let repos): 32 | view.setRepositories(repos) 33 | case .error(let error): 34 | alert("\(error)") 35 | } 36 | } 37 | 38 | // Search default query 39 | app.search(query: view.queryString()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/GitHubExampleWeb/WebBindings/WebDocument.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | 3 | @dynamicMemberLookup 4 | class JSObjectProxyBase: ConvertibleToJSValue { 5 | var ref: JSObject! 6 | init(_ ref: JSObject) { 7 | self.ref = ref 8 | } 9 | func jsValue() -> JSValue { 10 | return .object(ref) 11 | } 12 | } 13 | 14 | extension JSObjectProxyBase { 15 | subscript(dynamicMember name: String) -> ((ConvertibleToJSValue...) -> JSValue)? { 16 | return ref[dynamicMember: name] 17 | } 18 | subscript(dynamicMember name: String) -> JSValue { 19 | get { ref[dynamicMember: name] } 20 | set { ref[dynamicMember: name] = newValue } 21 | } 22 | subscript(_ index: Int) -> JSValue { 23 | get { ref[index] } 24 | set { ref[index] = newValue } 25 | } 26 | 27 | func get(_ name: String) -> JSValue { ref[name] } 28 | func set(_ name: String, _ value: JSValue) { ref[name] = value } 29 | func get(_ index: Int) -> JSValue { ref[index] } 30 | func set(_ index: Int, _ value: JSValue) { ref[index] = value } 31 | } 32 | 33 | class WebDocumentEvent { 34 | private let ref: JSObject 35 | 36 | init(_ ref: JSObject) { 37 | self.ref = ref 38 | } 39 | 40 | func preventDefault() { 41 | _ = ref.preventDefault!() 42 | } 43 | } 44 | 45 | class WebDocument: JSObjectProxyBase { 46 | var body: WebDocumentObject { WebDocumentObject(ref.body.object!) } 47 | func getElementById(_ id: String) -> WebDocumentObject { 48 | WebDocumentObject(ref.getElementById!(id).object!) 49 | } 50 | 51 | func createElement(_ tag: String) -> WebDocumentObject { 52 | WebDocumentObject(ref.createElement!(tag).object!) 53 | } 54 | } 55 | 56 | class WebDocumentObject: JSObjectProxyBase { 57 | 58 | var innerHTML: String { 59 | get { ref[#function].string! } 60 | set { ref[#function] = .string(newValue) } 61 | } 62 | 63 | var innerText: String { 64 | get { ref[#function].string! } 65 | set { ref[#function] = .string(newValue) } 66 | } 67 | 68 | @discardableResult 69 | func appendChild(_ child: WebDocumentObject) -> WebDocumentObject { 70 | WebDocumentObject(ref.appendChild!(child).object!) 71 | } 72 | 73 | private var subscriptions: [(event: String, listener: JSClosure)] = [] 74 | func addEventListener(_ eventType: String, listener: @escaping (WebDocumentEvent) -> Void) { 75 | let listener = JSClosure { args -> JSValue in 76 | let event = WebDocumentEvent(args[0].object!) 77 | listener(event) 78 | return .undefined 79 | } 80 | subscriptions.append((event: eventType, listener: listener)) 81 | _ = ref.addEventListener!(eventType, listener) 82 | } 83 | 84 | deinit { 85 | for (event, listener) in subscriptions { 86 | _ = ref.removeEventListener!(event, listener) 87 | } 88 | } 89 | } 90 | 91 | class WebIntersectionObserver: JSObjectProxyBase { 92 | static let ref = JSObject.global.IntersectionObserver.function! 93 | 94 | struct Entry: Codable { 95 | let isIntersecting: Bool 96 | } 97 | 98 | let jsClosure: JSClosure 99 | init(_ callback: @escaping ([Entry]) -> Void) { 100 | jsClosure = JSClosure { args -> JSValue in 101 | let entries: [Entry] = try! JSValueDecoder().decode(from: args[0]) 102 | callback(entries) 103 | return .undefined 104 | } 105 | super.init(Self.ref.new(jsClosure)) 106 | } 107 | 108 | func observe(_ target: WebDocumentObject) { 109 | _ = ref.observe!(target) 110 | } 111 | 112 | deinit { 113 | // Release JSClosure **after** releasing IntersectionObserver 114 | // to avoid use-after-free 115 | ref = nil 116 | jsClosure.release() 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/GitHubExampleWeb/WebBindings/WebFetch.swift: -------------------------------------------------------------------------------- 1 | import GitHubExample 2 | import JavaScriptKit 3 | 4 | let _jsFetch = JSObject.global.fetch.function! 5 | public func fetch(_ url: String) -> JSPromise { 6 | JSPromise(_jsFetch(url).object!)! 7 | } 8 | -------------------------------------------------------------------------------- /Sources/GitHubExampleWeb/WebBindings/WebJSON.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | 3 | class WebJSON { 4 | static let ref = JSObject.global.JSON.object! 5 | static func stringify(_ object: JSValue) -> String { 6 | ref.stringify!(object).string! 7 | } 8 | 9 | static func parse(_ string: String) -> JSValue { 10 | ref.parse!(string) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/GitHubExampleWeb/WebBindings/WebUtils.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | 3 | func alert(_ message: String) { 4 | _ = JSObject.global.alert!(message) 5 | } 6 | 7 | class WebConsole: JSObjectProxyBase { 8 | func log(_ text: String...) { 9 | _ = ref.log!(text.joined(separator: " ")) 10 | } 11 | 12 | func log(_ value: JSValue) { 13 | _ = ref.log!(value) 14 | } 15 | } 16 | 17 | let console = WebConsole(JSObject.global.console.object!) 18 | 19 | enum WebDevTool { 20 | static func debugger() { 21 | _ = JSObject.global._triggerDebugger!() 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /Sources/GitHubExampleWeb/WebFetchSession.swift: -------------------------------------------------------------------------------- 1 | import GitHubExample 2 | import JavaScriptKit 3 | 4 | struct MessageError: Error { 5 | let message: String 6 | } 7 | 8 | class PromiseBag: Task { 9 | var promises: [AnyObject] = [] 10 | } 11 | extension JSPromise: Task {} 12 | 13 | class WebFetchSession: NetworkSession { 14 | func get(_ request: R, _ callback: @escaping (Result) -> Void) -> Task where R: GitHubAPIRequest { 15 | let url = request.baseURL + request.path + request.queryParameters.reduce("?") { 16 | $0 + ($0 == "?" ? "" : "&") + "\($1.key)=\($1.value)" 17 | } 18 | 19 | let bag = PromiseBag() 20 | let fetchPromise = fetch(url) 21 | let jsonfyPromise = fetchPromise.then { response in 22 | response.object!.json!() 23 | } 24 | let decodePromise = jsonfyPromise.then { json -> JSValue in 25 | do { 26 | let response = try JSValueDecoder().decode( 27 | R.Response.self, from: json 28 | ) 29 | callback(.success(response)) 30 | } catch { 31 | callback(.failure(error)) 32 | } 33 | return .undefined 34 | } 35 | let catchPromise = decodePromise.catch { error -> JSValue in 36 | callback(.failure(MessageError(message: error.message))) 37 | return JSValue.undefined 38 | } 39 | bag.promises = [fetchPromise, jsonfyPromise, decodePromise, catchPromise] 40 | return bag 41 | } 42 | } 43 | 44 | #if DEBUG 45 | 46 | protocol ResponseMapBase { 47 | var requestType: Any.Type { get } 48 | var _response: Any { get } 49 | } 50 | 51 | struct ResponseMap: ResponseMapBase { 52 | var requestType: Any.Type { T.self } 53 | let response: T.Response 54 | var _response: Any { return response } 55 | } 56 | 57 | extension JSTimer: Task {} 58 | 59 | class NetworkMock: NetworkSession { 60 | 61 | static func decode(json: String) -> T { 62 | let json = WebJSON.parse(json) 63 | return try! JSValueDecoder().decode(from: json) 64 | } 65 | 66 | let responseMaps: [ResponseMapBase] = [ 67 | ResponseMap( 68 | response: decode(json: githubRepositoriesMock) 69 | ) 70 | ] 71 | 72 | func get(_ request: R, _ callback: @escaping (Result) -> Void) -> Task where R: GitHubAPIRequest { 73 | let response = responseMaps.first(where: { $0.requestType == R.self })!._response as! R.Response 74 | return JSTimer(millisecondsDelay: 1000) { 75 | callback(.success(response)) 76 | } 77 | } 78 | } 79 | #endif 80 | -------------------------------------------------------------------------------- /Sources/GitHubExampleWeb/main.swift: -------------------------------------------------------------------------------- 1 | import GitHubExample 2 | import JavaScriptKit 3 | 4 | let window = WebDocumentObject(JSObject.global) 5 | let document = WebDocument(JSObject.global.document.object!) 6 | #if DEBUG 7 | let session = NetworkMock() 8 | #else 9 | let session = WebFetchSession() 10 | #endif 11 | let app = GitHubExampleApp(networkSession: session) 12 | 13 | let view = GitHubView( 14 | body: document.body, 15 | searchForm: document.getElementById("github-search-form"), 16 | repositoryList: document.getElementById("github-repository-list"), 17 | loadMoreTag: document.getElementById("github-load-more") 18 | ) 19 | 20 | let viewController = GitHubViewController(view: view, app: app) 21 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 7 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/Components/RemoteImage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | final class ImageLoadService: ObservableObject { 5 | 6 | @Published private(set) var image: UIImage = UIImage() 7 | 8 | private var cancellables: [AnyCancellable] = [] 9 | 10 | func load(from url: URL) { 11 | URLSession.shared.dataTaskPublisher(for: url) 12 | .compactMap { (data, _) -> UIImage? in 13 | UIImage(data: data) 14 | } 15 | .catch { _ in Just(UIImage()) } 16 | .receive(on: RunLoop.main) 17 | .assign(to: \.image, on: self) 18 | .store(in: &cancellables) 19 | } 20 | 21 | } 22 | 23 | struct RemoteImage: View { 24 | 25 | @ObservedObject var imageLoad = ImageLoadService() 26 | 27 | init(url: URL) { 28 | imageLoad.load(from: url) 29 | } 30 | 31 | var body: some View { 32 | Image(uiImage: imageLoad.image).resizable().frame(width: 50, height: 50) 33 | } 34 | } 35 | 36 | struct RemoteImage_Previews: PreviewProvider { 37 | static var previews: some View { 38 | RemoteImage(url: URL(string: "https://avatars0.githubusercontent.com/u/10639145?v=4")!) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/Components/SearchBar.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | struct SearchBar: UIViewRepresentable { 5 | 6 | @Binding var text: String 7 | 8 | class Coordinator: NSObject, UISearchBarDelegate { 9 | 10 | @Binding var text: String 11 | 12 | init(text: Binding) { 13 | _text = text 14 | } 15 | 16 | func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { 17 | text = searchText 18 | } 19 | } 20 | 21 | func makeCoordinator() -> SearchBar.Coordinator { 22 | return Coordinator(text: $text) 23 | } 24 | 25 | func makeUIView(context: UIViewRepresentableContext) -> UISearchBar { 26 | let searchBar = UISearchBar(frame: .zero) 27 | searchBar.delegate = context.coordinator 28 | searchBar.searchBarStyle = .minimal 29 | return searchBar 30 | } 31 | 32 | func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext) { 33 | uiView.text = text 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/ContentView.swift: -------------------------------------------------------------------------------- 1 | import GitHubExample 2 | import SwiftUI 3 | import Combine 4 | 5 | class ContentViewModel: ObservableObject { 6 | let app: GitHubExampleApp 7 | @Published var repos: [Repository] = [] 8 | @Published var query: String = "Swift" 9 | 10 | var cancellables: [AnyCancellable] = [] 11 | 12 | init(app: GitHubExampleApp) { 13 | self.app = app 14 | app.subscribe { [weak self] event in 15 | DispatchQueue.main.async { 16 | switch event { 17 | case .repositories(let repos): 18 | self?.repos = repos 19 | case .error(let error): 20 | print(error) 21 | } 22 | } 23 | } 24 | $query.sink { queryString in 25 | app.search(query: queryString) 26 | } 27 | .store(in: &cancellables) 28 | } 29 | } 30 | 31 | extension Repository: Identifiable {} 32 | 33 | struct ContentView: View { 34 | 35 | @ObservedObject var viewModel: ContentViewModel 36 | 37 | var body: some View { 38 | NavigationView { 39 | VStack { 40 | SearchBar(text: $viewModel.query) 41 | List { 42 | ForEach(viewModel.repos) { repo in 43 | HStack { 44 | RemoteImage(url: URL(string: repo.owner.avatarURL)!) 45 | VStack(alignment: .leading) { 46 | Text(repo.fullName).font(.headline) 47 | Text(repo.description ?? "") 48 | } 49 | .padding() 50 | Spacer() 51 | } 52 | } 53 | Text("Load More").onAppear { 54 | self.viewModel.app.nextPage() 55 | } 56 | } 57 | } 58 | .navigationBarTitle("GitHub Search 🔍") 59 | 60 | } 61 | } 62 | } 63 | 64 | struct ContentView_Previews: PreviewProvider { 65 | static var previews: some View { 66 | ContentView(viewModel: ContentViewModel( 67 | app: GitHubExampleApp(networkSession: NativeNetworkSession()) 68 | )) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/NativeNetworkSession.swift: -------------------------------------------------------------------------------- 1 | import GitHubExample 2 | import Foundation 3 | 4 | extension URLSessionTask: Task {} 5 | 6 | class NativeNetworkSession: NetworkSession { 7 | func get(_ request: R, _ callback: @escaping (Result) -> Void) -> Task where R: GitHubAPIRequest { 8 | let url = URL(string: request.baseURL + request.path + request.queryParameters.reduce("?") { 9 | $0 + ($0 == "?" ? "" : "&") + "\($1.key)=\($1.value)" 10 | })! 11 | let task = URLSession.shared.dataTask(with: url) { (data, response, error) in 12 | if let error = error { 13 | callback(.failure(error)) 14 | return 15 | } 16 | do { 17 | let response = try JSONDecoder().decode(R.Response.self, from: data!) 18 | callback(.success(response)) 19 | } catch { 20 | callback(.failure(error)) 21 | } 22 | } 23 | task.resume() 24 | return task 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/GitHubExampleiOS/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import GitHubExample 4 | 5 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 6 | 7 | var window: UIWindow? 8 | 9 | let networkSession = NativeNetworkSession() 10 | lazy var app = GitHubExampleApp(networkSession: self.networkSession) 11 | lazy var viewModel = ContentViewModel(app: app) 12 | 13 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 14 | let contentView = ContentView(viewModel: viewModel) 15 | 16 | if let windowScene = scene as? UIWindowScene { 17 | let window = UIWindow(windowScene: windowScene) 18 | window.rootViewController = UIHostingController(rootView: contentView) 19 | self.window = window 20 | window.makeKeyAndVisible() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/GitHubExampleTests/GitHubExampleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import GitHubExample 3 | 4 | class GitHubExampleTests: XCTestCase { 5 | 6 | func testPagination() { 7 | struct RequestMock: GitHubAPIRequest, PaginationRequest { 8 | var path: String { "path/to/mock" } 9 | typealias Response = Int 10 | let page: Int 11 | let limit: Int 12 | 13 | func next(from response: Int) -> RequestMock? { 14 | return limit <= page ? nil : RequestMock(page: page + 1, limit: limit) 15 | } 16 | } 17 | class NetworkMock: NetworkSession { 18 | class NopTask: Task {} 19 | var requests: [RequestMock] = [] 20 | var pendingCallbacks: [() -> Void] = [] 21 | 22 | func resumeRequest() { 23 | pendingCallbacks.removeFirst()() 24 | } 25 | 26 | func get(_ request: R, _ callback: @escaping (Result) -> Void) -> Task where R: GitHubAPIRequest { 27 | assert(request is RequestMock) 28 | requests.append(request as! RequestMock) 29 | pendingCallbacks.append { callback(.success(Int(0) as! R.Response)) } 30 | return NopTask() 31 | } 32 | } 33 | let networkMock = NetworkMock() 34 | let pagination = Pagination(networkSession: networkMock) 35 | var initialEvents: [Int] = [] 36 | var nextEvents: [Int] = [] 37 | pagination.subscribe { event in 38 | switch event { 39 | case .initial(let event): initialEvents.append(event) 40 | case .next(let event): nextEvents.append(event) 41 | case .error: fatalError("unreachable") 42 | } 43 | } 44 | 45 | // Request before initialize 46 | pagination.next() 47 | XCTAssertTrue(networkMock.requests.isEmpty) 48 | 49 | // Initialize 50 | pagination.initialize(RequestMock(page: 0, limit: 3)) 51 | XCTAssertEqual(networkMock.requests.map(\.page), [0]) 52 | 53 | // Next before request didn't finished 54 | pagination.next() 55 | XCTAssertEqual(networkMock.requests.map(\.page), [0]) 56 | 57 | // Finished initial request and ignored next request 58 | networkMock.resumeRequest() 59 | XCTAssertEqual(initialEvents.count, 1) 60 | XCTAssertEqual(nextEvents.count, 0) 61 | XCTAssertEqual(networkMock.requests.map(\.page), [0]) 62 | XCTAssertTrue(networkMock.pendingCallbacks.isEmpty) 63 | 64 | // Request twice at same time 65 | pagination.next() 66 | pagination.next() 67 | XCTAssertEqual(initialEvents.count, 1) 68 | XCTAssertEqual(nextEvents.count, 0) 69 | XCTAssertEqual(networkMock.requests.map(\.page), [0, 1]) 70 | 71 | networkMock.resumeRequest() 72 | XCTAssertEqual(nextEvents.count, 1) 73 | XCTAssertEqual(networkMock.requests.map(\.page), [0, 1]) 74 | XCTAssertTrue(networkMock.pendingCallbacks.isEmpty) 75 | 76 | pagination.next() 77 | networkMock.resumeRequest() 78 | // 4th request 79 | pagination.next() 80 | networkMock.resumeRequest() 81 | XCTAssertTrue(pagination.isFinished) 82 | XCTAssertEqual(networkMock.requests.map(\.page), [0, 1, 2, 3]) 83 | 84 | // 5th request 85 | pagination.next() 86 | XCTAssertEqual(networkMock.requests.map(\.page), [0, 1, 2, 3]) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /js/index.js: -------------------------------------------------------------------------------- 1 | import { SwiftRuntime } from "javascript-kit-swift"; 2 | import { WASI } from "@wasmer/wasi"; 3 | import { WasmFs } from "@wasmer/wasmfs"; 4 | 5 | 6 | global._triggerDebugger = () => { 7 | debugger 8 | }; 9 | 10 | const startWasiTask = async () => { 11 | 12 | const swift = new SwiftRuntime(); 13 | const wasmFs = new WasmFs(); 14 | 15 | // Output stdout and stderr to console 16 | const originalWriteSync = wasmFs.fs.writeSync; 17 | wasmFs.fs.writeSync = (fd, buffer, offset, length, position) => { 18 | const text = new TextDecoder("utf-8").decode(buffer); 19 | switch (fd) { 20 | case 1: 21 | console.log(text); 22 | break; 23 | case 2: 24 | console.error(text); 25 | break; 26 | } 27 | return originalWriteSync(fd, buffer, offset, length, position); 28 | }; 29 | 30 | let wasi = new WASI({ 31 | args: [], env: {}, 32 | bindings: { 33 | ...WASI.defaultBindings, 34 | fs: wasmFs.fs 35 | } 36 | }); 37 | 38 | const response = await fetch("GitHubExampleWeb.wasm"); 39 | const importObject = { 40 | wasi_snapshot_preview1: wasi.wasiImport, 41 | javascript_kit: swift.importObjects(), 42 | }; 43 | 44 | const { instance } = await (async () => { 45 | if (WebAssembly.instantiateStreaming) { 46 | return await WebAssembly.instantiateStreaming(response, importObject); 47 | } else { 48 | const responseArrayBuffer = await response.arrayBuffer(); 49 | const wasmBytes = new Uint8Array(responseArrayBuffer).buffer; 50 | return await WebAssembly.instantiate(wasmBytes, importObject); 51 | } 52 | })(); 53 | 54 | swift.setInstance(instance); 55 | wasi.start(instance); 56 | }; 57 | 58 | startWasiTask().catch(console.error); 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GitHubExample", 3 | "dependencies": { 4 | "@wasmer/wasi": "^0.12.0", 5 | "@wasmer/wasmfs": "^0.12.0", 6 | "javascript-kit-swift": "0.8.0" 7 | }, 8 | "devDependencies": { 9 | "@swiftwasm/swift-webpack-plugin": "1.0.9", 10 | "copy-webpack-plugin": "^5.1.2", 11 | "webpack": "^4.46.0", 12 | "webpack-cli": "^3.3.12", 13 | "webpack-dev-server": "^3.11.2" 14 | }, 15 | "scripts": { 16 | "build": "webpack --config webpack.dev.js", 17 | "build:prod": "webpack --config webpack.prod.js", 18 | "watch": "webpack --watch --config webpack.dev.js", 19 | "start": "webpack-dev-server --open --config webpack.dev.js" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /project.yml: -------------------------------------------------------------------------------- 1 | name: GitHubExampleiOS 2 | options: 3 | bundleIdPrefix: org.swiftwasm 4 | targets: 5 | GitHubExampleiOS: 6 | type: application 7 | platform: iOS 8 | sources: 9 | - Sources/GitHubExampleiOS 10 | dependencies: 11 | - target: GitHubExample 12 | GitHubExample: 13 | type: library.static 14 | platform: iOS 15 | sources: 16 | - Sources/GitHubExample 17 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GitHub Search🔍 6 | 7 | 8 | 9 | 10 | 11 | GitHub Search🔍 12 | 13 | 14 | 15 | Search 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
\(repo.description ?? "")