├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── Launch.storyboard ├── Makefile ├── README.md ├── Sources ├── AppDelegate.swift ├── BridgingHeader.h ├── EmulatorCore.h ├── EmulatorCore.m ├── MachineLoader.swift ├── TerminalInputAccessoryView.swift └── TerminalViewController.swift └── project.yml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: macos-10.15 8 | env: 9 | DEVELOPER_DIR: /Applications/Xcode_11.5.app/Contents/Developer 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | submodules: true 14 | - run: brew install xcodegen 15 | - run: make 16 | - run: set -o pipefail && xcodebuild -project TinyEMU-iOS.xcodeproj -scheme TinyEMU-iOS -sdk iphonesimulator build | xcpretty 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build products 2 | *.xcodeproj 3 | Info.plist 4 | Xterm.js-Info.plist 5 | node_modules 6 | package-lock.json 7 | Downloads 8 | Assets/Machine.bundle 9 | 10 | # macOS-specific 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "TinyEMU"] 2 | path = Vendors/TinyEMU 3 | url = https://github.com/fernandotcl/TinyEMU 4 | [submodule "SwiftTerm"] 5 | path = Vendors/SwiftTerm 6 | url = https://github.com/migueldeicaza/SwiftTerm.git 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Fernando Tarlá Cardoso Lemos 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, 7 | this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 13 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 14 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 15 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 16 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 17 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 18 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 19 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 20 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 21 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 22 | -------------------------------------------------------------------------------- /Launch.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 | 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: TinyEMU-iOS.xcodeproj 3 | 4 | TinyEMU-iOS.xcodeproj: project.yml Assets/Machine.bundle 5 | xcodegen 6 | 7 | Assets/Machine.bundle: Downloads/kernel.tar.gz Downloads/root.img.bz2 8 | rm -rf $@.tmp 9 | mkdir -p $@.tmp 10 | tar -O -xf Downloads/kernel.tar.gz diskimage-linux-riscv-2018-09-23/bbl64.bin >$@.tmp/bbl.bin 11 | tar -O -xf Downloads/kernel.tar.gz diskimage-linux-riscv-2018-09-23/kernel-riscv64.bin >$@.tmp/kernel.bin 12 | bzcat Downloads/root.img.bz2 >$@.tmp/root.img 13 | chmod -x $@.tmp/* 14 | @echo -n '' >$@.tmp/Machine.plist 15 | @echo '' >$@.tmp/Machine.plist 16 | @echo '' >>$@.tmp/Machine.plist 17 | @echo '' >>$@.tmp/Machine.plist 18 | @echo '' >>$@.tmp/Machine.plist 19 | plutil -insert BIOS -string bbl.bin $@.tmp/Machine.plist 20 | plutil -insert Kernel -string kernel.bin $@.tmp/Machine.plist 21 | plutil -insert KernelCommandLine -string 'console=hvc0 root=/dev/vda ro' $@.tmp/Machine.plist 22 | plutil -insert RootDrive -string root.img $@.tmp/Machine.plist 23 | mv $@.tmp $@ 24 | 25 | Downloads/kernel.tar.gz: 26 | mkdir -p Downloads 27 | curl -L -o $@.tmp https://bellard.org/tinyemu/diskimage-linux-riscv-2018-09-23.tar.gz 28 | @echo '808ecc1b32efdd76103172129b77b46002a616dff2270664207c291e4fde9e14 Downloads/kernel.tar.gz.tmp' >$@.tmp.sha256 29 | shasum -a 256 -c $@.tmp.sha256 30 | mv $@.tmp $@ 31 | 32 | Downloads/root.img.bz2: 33 | mkdir -p Downloads 34 | curl -L -o $@.tmp https://github.com/fernandotcl/TinyEMU-iOS-Images/releases/download/2019-04-01/root.img.bz2 35 | @echo '3cddbcf608aa2393b82a6769623713305e2aa29cc271bb79b2ecfd163f4575fa Downloads/root.img.bz2.tmp' >$@.tmp.sha256 36 | shasum -a 256 -c $@.tmp.sha256 37 | mv $@.tmp $@ 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TinyEMU-iOS 2 | 3 | [![Build](https://github.com/fernandotcl/TinyEMU-iOS/workflows/Build/badge.svg)][GitHub Actions] 4 | 5 | This is an experimental iOS app that embeds [TinyEMU][]. 6 | 7 | [GitHub Actions]: https://github.com/fernandotcl/TinyEMU-iOS/actions?query=workflow%3ABuild 8 | [TinyEMU]: https://github.com/fernandotcl/TinyEMU 9 | 10 | ## Usage 11 | 12 | This app is not available in the App Store or in TestFlight. To build it from source, make sure you have the latest version of Xcode installed. You'll also need [XcodeGen][]. 13 | 14 | [XcodeGen]: https://github.com/yonaskolb/XcodeGen 15 | 16 | Before building, make sure you have checked out the git submodules by running `git submodule update`. You can then run `make` to download and set up [Fabrice Bellard's disk images][images] and create the project file. Finally, open `TinyEMU-iOS.xcodeproj`, change the code signing settings as needed and run. 17 | 18 | [images]: https://bellard.org/tinyemu/ 19 | 20 | ## Credits 21 | 22 | TinyEMU was originally created by [Fabrice Bellard][fabrice]. This app was created by [Fernando Tarlá Cardoso Lemos][fernando]. 23 | 24 | [fabrice]: https://bellard.org 25 | [fernando]: mailto:fernandotcl@gmail.com 26 | 27 | ## License 28 | 29 | TinyEMU-iOS is available under the BSD 2-clause license. See the LICENSE file for more information. 30 | -------------------------------------------------------------------------------- /Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // TinyEMU-iOS 4 | // 5 | // Created by Fernando Lemos on 3/24/19. 6 | // 7 | // Refer to the LICENSE file for licensing information. 8 | // 9 | 10 | import UIKit 11 | 12 | 13 | @UIApplicationMain 14 | class AppDelegate: NSObject { 15 | 16 | var window: UIWindow? 17 | private var terminalViewController: TerminalViewController! 18 | private let machineLoader: MachineLoader 19 | private let emulatorCore: EmulatorCore 20 | 21 | override init() { 22 | let bundleURL = Bundle.main.url(forResource: "Machine", 23 | withExtension: "bundle")! 24 | machineLoader = MachineLoader() 25 | try! machineLoader.load(Bundle(url: bundleURL)!) 26 | 27 | emulatorCore = EmulatorCore(configPath: 28 | machineLoader.configFileURL.path) 29 | 30 | super.init() 31 | } 32 | } 33 | 34 | extension AppDelegate: UIApplicationDelegate { 35 | 36 | func applicationDidFinishLaunching(_ application: UIApplication) { 37 | let window = UIWindow(frame: UIScreen.main.bounds) 38 | self.window = window 39 | 40 | terminalViewController = TerminalViewController() 41 | terminalViewController.delegate = self 42 | window.rootViewController = terminalViewController 43 | window.makeKeyAndVisible() 44 | 45 | emulatorCore.delegate = self 46 | emulatorCore.start() 47 | } 48 | } 49 | 50 | extension AppDelegate: EmulatorCoreDelegate { 51 | 52 | func emulatorCore(_ core: EmulatorCore, didReceiveOutput data: Data) { 53 | terminalViewController.receiveTerminalOutput(data) 54 | } 55 | } 56 | 57 | extension AppDelegate: TerminalViewControllerDelegate { 58 | 59 | func terminalViewController(_ viewController: TerminalViewController, 60 | resizeWithColumns columns: Int, 61 | rows: Int) { 62 | emulatorCore.resize(withColumns: columns, rows: rows) 63 | } 64 | 65 | func terminalViewController(_ viewController: TerminalViewController, 66 | send data: Data) { 67 | emulatorCore.sendInput(data) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/BridgingHeader.h: -------------------------------------------------------------------------------- 1 | // 2 | // BridgingHeader.h 3 | // TinyEMU-iOS 4 | // 5 | // Created by Fernando Lemos on 3/24/19. 6 | // 7 | // Refer to the LICENSE file for licensing information. 8 | // 9 | 10 | #import "EmulatorCore.h" 11 | -------------------------------------------------------------------------------- /Sources/EmulatorCore.h: -------------------------------------------------------------------------------- 1 | // 2 | // EmulatorCore.h 3 | // TinyEMU-iOS 4 | // 5 | // Created by Fernando Lemos on 3/24/19. 6 | // 7 | // Refer to the LICENSE file for licensing information. 8 | // 9 | 10 | @import Foundation; 11 | 12 | NS_ASSUME_NONNULL_BEGIN 13 | 14 | @protocol EmulatorCoreDelegate; 15 | 16 | 17 | @interface EmulatorCore: NSObject 18 | 19 | - (instancetype)initWithConfigPath:(NSString *)path NS_DESIGNATED_INITIALIZER; 20 | - (instancetype)init NS_UNAVAILABLE; 21 | 22 | @property (nonatomic, weak) id delegate; 23 | 24 | - (void)start; 25 | 26 | - (void)sendInput:(NSData *)data; 27 | 28 | - (void)resizeWithColumns:(NSInteger)columns rows:(NSInteger)rows; 29 | 30 | @end 31 | 32 | 33 | @protocol EmulatorCoreDelegate 34 | 35 | - (void)emulatorCore:(EmulatorCore *)core didReceiveOutput:(NSData *)data; 36 | 37 | @end 38 | 39 | 40 | NS_ASSUME_NONNULL_END 41 | -------------------------------------------------------------------------------- /Sources/EmulatorCore.m: -------------------------------------------------------------------------------- 1 | // 2 | // EmulatorCore.m 3 | // TinyEMU-iOS 4 | // 5 | // Created by Fernando Lemos on 3/24/19. 6 | // 7 | // Refer to the LICENSE file for licensing information. 8 | // 9 | 10 | @import Darwin; 11 | 12 | #import "EmulatorCore.h" 13 | 14 | 15 | int temu_main(int argc, const char **argv); 16 | 17 | 18 | static NSInteger consoleColumns = 80; 19 | static NSInteger consoleRows = 25; 20 | 21 | void console_get_size(void *opaque, int *pw, int *ph) 22 | { 23 | *pw = (int)consoleColumns; 24 | *ph = (int)consoleRows; 25 | } 26 | 27 | 28 | @interface EmulatorCore () 29 | 30 | @property (nonatomic, copy) NSString *configPath; 31 | @property (nonatomic) int inputDescriptor; 32 | @property (nonatomic) dispatch_source_t outputSource; 33 | 34 | @end 35 | 36 | 37 | @implementation EmulatorCore 38 | 39 | - (instancetype)initWithConfigPath:(NSString *)path 40 | { 41 | self = [super init]; 42 | if (self != nil) { 43 | self.configPath = path; 44 | } 45 | return self; 46 | } 47 | 48 | - (void)start 49 | { 50 | int fds[2]; 51 | int flags; 52 | 53 | // Replace stdin with a pipe 54 | if (pipe(fds) == -1) { 55 | [NSException raise:NSInternalInconsistencyException format:@"pipe() failed"]; 56 | } 57 | if (dup2(fds[0], STDIN_FILENO) != STDIN_FILENO) { 58 | [NSException raise:NSInternalInconsistencyException format:@"dup2() failed"]; 59 | } 60 | self.inputDescriptor = fds[1]; 61 | 62 | // Replace stdout with a pipe 63 | if (pipe(fds) == -1) { 64 | [NSException raise:NSInternalInconsistencyException format:@"pipe() failed"]; 65 | } 66 | flags = fcntl(fds[0], F_GETFL); 67 | if (flags == -1) { 68 | [NSException raise:NSInternalInconsistencyException format:@"fcntl() failed"]; 69 | } 70 | if (fcntl(fds[0], F_SETFL, flags | O_NONBLOCK) == -1) { 71 | [NSException raise:NSInternalInconsistencyException format:@"fcntl() failed"]; 72 | } 73 | if (dup2(fds[1], STDOUT_FILENO) != STDOUT_FILENO) { 74 | [NSException raise:NSInternalInconsistencyException format:@"dup2() failed"]; 75 | } 76 | 77 | // Set that up to dispatch to the main queue 78 | self.outputSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, 79 | (uintptr_t)fds[0], 80 | 0, 81 | dispatch_get_main_queue()); 82 | __weak EmulatorCore *weakSelf = self; 83 | dispatch_source_set_event_handler(self.outputSource, ^{ 84 | int descriptor = (int)dispatch_source_get_handle(weakSelf.outputSource); 85 | uint8_t buf[1024]; 86 | for (;;) { 87 | ssize_t len = read(descriptor, buf, sizeof(buf) - 1); 88 | if (len == 0) break; 89 | if (len < 0) { 90 | if (errno == EAGAIN) break; 91 | if (errno == EINTR) continue; 92 | [NSException raise:NSInternalInconsistencyException format:@"read() failed"]; 93 | } 94 | [self.delegate emulatorCore:self didReceiveOutput:[NSData dataWithBytes:buf length:len]]; 95 | } 96 | }); 97 | dispatch_resume(self.outputSource); 98 | 99 | // Run the emulator in a separate queue 100 | NSString *configPathCopy = [self.configPath copy]; 101 | dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ 102 | const char *argv[2]; 103 | argv[0] = "temu"; 104 | argv[1] = configPathCopy.UTF8String; 105 | temu_main(2, &argv[0]); 106 | }); 107 | } 108 | 109 | - (void)sendInput:(NSData *)data 110 | { 111 | uintptr_t total = 0; 112 | do { 113 | ssize_t len = write(self.inputDescriptor, &data.bytes[total], data.length - total); 114 | if (len < 0) { 115 | if (errno == EAGAIN) break; 116 | if (errno == EINTR) continue; 117 | [NSException raise:NSInternalInconsistencyException format:@"write() failed"]; 118 | } 119 | total += len; 120 | } while (data.length > total); 121 | } 122 | 123 | - (void)resizeWithColumns:(NSInteger)columns rows:(NSInteger)rows 124 | { 125 | if (consoleColumns == columns && consoleRows == rows) return; 126 | 127 | consoleColumns = columns; 128 | consoleRows = rows; 129 | 130 | kill(getpid(), SIGWINCH); 131 | } 132 | 133 | @end 134 | -------------------------------------------------------------------------------- /Sources/MachineLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MachineLoaderr.swift 3 | // TinyEMU-iOS 4 | // 5 | // Created by Fernando Lemos on 3/31/19. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | class MachineLoader { 12 | 13 | enum MachineLoaderError: Error { 14 | case missingDescription 15 | case invalidDescription 16 | } 17 | 18 | private struct MachineDescription: Codable { 19 | var bios: String 20 | var kernel: String? 21 | var kernelCommandLine: String? 22 | var rootDrive: String? 23 | 24 | private enum CodingKeys: String, CodingKey { 25 | case bios = "BIOS" 26 | case kernel = "Kernel" 27 | case kernelCommandLine = "KernelCommandLine" 28 | case rootDrive = "RootDrive" 29 | } 30 | } 31 | 32 | let configFileURL: URL 33 | 34 | init() { 35 | let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), 36 | isDirectory: true) 37 | configFileURL = temporaryDirectoryURL.appendingPathComponent("temu.cfg") 38 | } 39 | 40 | deinit { 41 | try? FileManager.default.removeItem(at: configFileURL) 42 | } 43 | 44 | func load(_ bundle: Bundle) throws { 45 | guard let plistURL = bundle.url(forResource: "Machine", 46 | withExtension: "plist") else { 47 | throw MachineLoaderError.missingDescription 48 | } 49 | let plistData = try Data(contentsOf: plistURL) 50 | 51 | let decoder = PropertyListDecoder() 52 | let description = try decoder.decode(MachineDescription.self, from: plistData) 53 | 54 | var config = """ 55 | { 56 | version: 1, 57 | machine: "riscv64", 58 | memory_size: 128, 59 | 60 | """ 61 | config += " bios: \"\(bundle.bundlePath)/\(description.bios)\",\n" 62 | if let kernel = description.kernel { 63 | config += " kernel: \"\(bundle.bundlePath)/\(kernel)\",\n" 64 | } 65 | if let commandLine = description.kernelCommandLine { 66 | config += " cmdline: \"\(commandLine)\",\n" 67 | } 68 | if let rootDrive = description.rootDrive { 69 | config += " drive0: { file: \"\(bundle.bundlePath)/\(rootDrive)\" },\n" 70 | } 71 | if let url = FileManager.default 72 | .urls(for: .documentDirectory, in: .userDomainMask).first { 73 | config += """ 74 | fs0: { 75 | file: "\(url.path)", 76 | tag: "/dev/hostfs", 77 | }, 78 | """ 79 | } 80 | config += """ 81 | eth0: { driver: "user" }, 82 | } 83 | """ 84 | try config.write(to: configFileURL, 85 | atomically: true, 86 | encoding: .utf8) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/TerminalInputAccessoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TerminalInputAccessoryView.swift 3 | // TinyEMU-iOS 4 | // 5 | // Created by Fernando Lemos on 4/1/19. 6 | // 7 | // Refer to the LICENSE file for licensing information. 8 | // 9 | 10 | import UIKit 11 | 12 | 13 | class TerminalInputAccessoryView: UIView { 14 | 15 | weak var delegate: TerminalInputAccessoryViewDelegate? 16 | 17 | enum Key { 18 | case escape 19 | case control 20 | case alternate 21 | case tab 22 | case home 23 | case end 24 | case arrowLeft 25 | case arrowUp 26 | case arrowDown 27 | case arrowRight 28 | } 29 | 30 | private struct KeyDescriptor { 31 | let key: Key 32 | let buttonTitle: String 33 | let priority: Int 34 | } 35 | 36 | private(set) var enabledModifiers: UIKeyModifierFlags = [] 37 | 38 | private var keyDescriptors: [KeyDescriptor] = [ 39 | KeyDescriptor(key: .escape, buttonTitle: "esc", priority: 0), 40 | KeyDescriptor(key: .control, buttonTitle: "^", priority: 0), 41 | KeyDescriptor(key: .alternate, buttonTitle: "⎇", priority: 2), 42 | KeyDescriptor(key: .tab, buttonTitle: "⇥", priority: 1), 43 | KeyDescriptor(key: .home, buttonTitle: "↖︎", priority: 3), 44 | KeyDescriptor(key: .end, buttonTitle: "↘︎", priority: 3), 45 | KeyDescriptor(key: .arrowLeft, buttonTitle: "←", priority: 0), 46 | KeyDescriptor(key: .arrowUp, buttonTitle: "↑", priority: 0), 47 | KeyDescriptor(key: .arrowDown, buttonTitle: "↓", priority: 0), 48 | KeyDescriptor(key: .arrowRight, buttonTitle: "→", priority: 0), 49 | ] 50 | 51 | private var buttons: [KeyButton] 52 | 53 | override init(frame: CGRect) { 54 | buttons = keyDescriptors.map { 55 | let button = KeyButton(type: .custom) 56 | 57 | button.setTitle($0.buttonTitle, for: []) 58 | 59 | button.setTitleColor(.systemGray, for: []) 60 | button.normalBackgroundColor = .systemGray5 61 | button.highlightedBackgroundColor = .systemGray4 62 | 63 | button.isPointerInteractionEnabled = true 64 | button.pointerStyleProvider = { button, effect, shape in 65 | let preview = UITargetedPreview(view: button) 66 | return UIPointerStyle(effect: .hover( 67 | preview, preferredTintMode: .overlay, 68 | prefersShadow: false, prefersScaledContent: false) 69 | ) 70 | } 71 | 72 | return button 73 | } 74 | 75 | var superFrame = frame 76 | superFrame.size.height = 44 77 | super.init(frame: superFrame) 78 | 79 | backgroundColor = .systemGray5 80 | 81 | for button in buttons { 82 | button.addTarget(self, 83 | action: #selector(didTapButton(button:)), 84 | for: .touchUpInside) 85 | addSubview(button) 86 | } 87 | } 88 | 89 | required init?(coder aDecoder: NSCoder) { fatalError() } 90 | 91 | override func safeAreaInsetsDidChange() { 92 | 93 | // UIKit installs a constraint setting a constant height matching the initial 94 | // height for the input accessory view at the time the view is made the input 95 | // accessory view. So we need to update that once we get new safe area insets. 96 | // Clearly nobody thought this through... 97 | for constraint in constraints { 98 | if constraint.firstAttribute == .height { 99 | constraint.constant = 44 + safeAreaInsets.bottom 100 | break 101 | } 102 | } 103 | 104 | super.safeAreaInsetsDidChange() 105 | } 106 | 107 | override func layoutSubviews() { 108 | super.layoutSubviews() 109 | 110 | var visibleButtons = buttons 111 | 112 | // Truncate the list of visible buttons as needed 113 | let availableWidth = bounds.size.width - safeAreaInsets.left - safeAreaInsets.right 114 | let minButtonWidth: CGFloat = 44 115 | var buttonWidth: CGFloat = 0 116 | var cutoffPriority = 4 117 | while buttonWidth < minButtonWidth && cutoffPriority > 0 { 118 | cutoffPriority -= 1 119 | visibleButtons = keyDescriptors 120 | .enumerated() 121 | .filter { $0.element.priority <= cutoffPriority } 122 | .map { buttons[$0.offset] } 123 | buttonWidth = availableWidth / CGFloat(visibleButtons.count) 124 | } 125 | 126 | // Lay out the buttons 127 | var remainder: CGFloat = 0 128 | var frame = CGRect(x: safeAreaInsets.left, 129 | y: 0, 130 | width: 0, 131 | height: bounds.height - safeAreaInsets.bottom) 132 | for button in visibleButtons { 133 | let width = buttonWidth + remainder 134 | let actualWidth = floor(width) 135 | remainder = width - actualWidth 136 | frame.origin.x = frame.maxX 137 | frame.size.width = actualWidth 138 | button.frame = frame 139 | } 140 | 141 | // Adjust their visibility 142 | for button in buttons { 143 | button.alpha = visibleButtons.contains(button) ? 1 : 0 144 | } 145 | } 146 | 147 | @objc private func didTapButton(button: KeyButton) { 148 | guard let index = buttons.firstIndex(of: button) else { return } 149 | let key = keyDescriptors[index].key 150 | 151 | switch key { 152 | case .control: 153 | if enabledModifiers.contains(.control) { 154 | enabledModifiers.remove(.control) 155 | button.isStickyHighlighted = false 156 | } else { 157 | enabledModifiers.insert(.control) 158 | button.isStickyHighlighted = true 159 | } 160 | case .alternate: 161 | if enabledModifiers.contains(.alternate) { 162 | enabledModifiers.remove(.alternate) 163 | button.isStickyHighlighted = false 164 | } else { 165 | enabledModifiers.insert(.alternate) 166 | button.isStickyHighlighted = true 167 | } 168 | default: 169 | break 170 | } 171 | 172 | delegate?.terminalInputAccessoryView(self, didTapKey: key) 173 | } 174 | 175 | func clearModifiers() { 176 | enabledModifiers = [] 177 | for button in buttons { 178 | button.isStickyHighlighted = false 179 | } 180 | } 181 | } 182 | 183 | // MARK: - Button view 184 | 185 | private class KeyButton: UIButton { 186 | 187 | var normalBackgroundColor: UIColor? { 188 | didSet { adjustColors() } 189 | } 190 | var highlightedBackgroundColor: UIColor? { 191 | didSet { adjustColors() } 192 | } 193 | 194 | override var isHighlighted: Bool { 195 | didSet { adjustColors() } 196 | } 197 | var isStickyHighlighted = false { 198 | didSet { adjustColors() } 199 | } 200 | 201 | private func adjustColors() { 202 | backgroundColor = isHighlighted || isStickyHighlighted ? 203 | highlightedBackgroundColor : normalBackgroundColor 204 | } 205 | } 206 | 207 | // MARK: - Delegate protocol 208 | 209 | protocol TerminalInputAccessoryViewDelegate: AnyObject { 210 | 211 | func terminalInputAccessoryView( 212 | _ view: TerminalInputAccessoryView, 213 | didTapKey key: TerminalInputAccessoryView.Key) 214 | } 215 | -------------------------------------------------------------------------------- /Sources/TerminalViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TerminalViewController.swift 3 | // TinyEMU-iOS 4 | // 5 | // Created by Fernando Lemos on 3/25/19. 6 | // 7 | // Refer to the LICENSE file for licensing information. 8 | // 9 | 10 | import SwiftTerm 11 | import UIKit 12 | import WebKit 13 | 14 | 15 | class TerminalViewController: UIViewController { 16 | 17 | weak var delegate: TerminalViewControllerDelegate? 18 | 19 | private var terminalView: TerminalView! 20 | private var terminalInputAccessoryView: TerminalInputAccessoryView! 21 | 22 | private var keyboardInset: CGFloat = 0 23 | 24 | private var tapGestureRecognizer: UIGestureRecognizer! 25 | 26 | override func viewDidLoad() { 27 | super.viewDidLoad() 28 | 29 | terminalView = TerminalView() 30 | terminalView.terminalDelegate = self 31 | view.addSubview(terminalView) 32 | 33 | terminalInputAccessoryView = TerminalInputAccessoryView() 34 | terminalInputAccessoryView.delegate = self 35 | terminalView.inputAccessoryView = terminalInputAccessoryView 36 | 37 | setupColors() 38 | 39 | tapGestureRecognizer = UITapGestureRecognizer( 40 | target: self, action: #selector(tapGestureRecognizerCallback)) 41 | tapGestureRecognizer.delegate = self 42 | view.addGestureRecognizer(tapGestureRecognizer) 43 | 44 | NotificationCenter.default 45 | .addObserver(self, 46 | selector: #selector(keyboardWillChangeFrame), 47 | name: UIApplication.keyboardWillChangeFrameNotification, 48 | object: nil) 49 | } 50 | 51 | override func viewDidAppear(_ animated: Bool) { 52 | super.viewDidAppear(animated) 53 | 54 | terminalView.becomeFirstResponder() 55 | } 56 | 57 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 58 | super.traitCollectionDidChange(previousTraitCollection) 59 | 60 | setupColors() 61 | } 62 | 63 | private func setupColors() { 64 | view.backgroundColor = .systemBackground 65 | 66 | terminalView.backgroundColor = .systemBackground 67 | terminalView.nativeBackgroundColor = .systemBackground 68 | terminalView.nativeForegroundColor = .label 69 | } 70 | } 71 | 72 | // MARK: - Layout 73 | 74 | extension TerminalViewController { 75 | 76 | override func viewDidLayoutSubviews() { 77 | super.viewDidLayoutSubviews() 78 | 79 | var margins = view.safeAreaInsets 80 | margins.top = max(margins.top, 25) 81 | margins.left = max(margins.left, 5) 82 | margins.bottom = max(max(margins.top, 5), keyboardInset) 83 | margins.right = max(margins.right, 5) 84 | terminalView.frame = view.bounds.inset(by: margins) 85 | } 86 | 87 | override func viewSafeAreaInsetsDidChange() { 88 | super.viewSafeAreaInsetsDidChange() 89 | 90 | view.setNeedsLayout() 91 | } 92 | 93 | @objc private func keyboardWillChangeFrame(_ notification: Notification) { 94 | let frameKey = UIApplication.keyboardFrameEndUserInfoKey 95 | guard let frame = (notification.userInfo?[frameKey] as? NSValue)? 96 | .cgRectValue else { return } 97 | 98 | let intersection = view.bounds.intersection(frame) 99 | let inset: CGFloat 100 | if intersection.isNull { 101 | inset = 0 102 | } else { 103 | inset = intersection.height 104 | } 105 | 106 | if inset != keyboardInset { 107 | keyboardInset = inset 108 | view.setNeedsLayout() 109 | } 110 | } 111 | } 112 | 113 | // MARK: - Terminal I/O 114 | 115 | extension TerminalViewController { 116 | 117 | func receiveTerminalOutput(_ data: Data) { 118 | terminalView.feed(byteArray: [UInt8](data)[...]) 119 | } 120 | 121 | private func sendTerminalSequence( 122 | _ sequence: String, 123 | additionalModifiers: UIKeyModifierFlags = []) { 124 | 125 | guard let data = sequence.data(using: .ascii), !data.isEmpty else { return } 126 | 127 | let modifiers = terminalInputAccessoryView.enabledModifiers 128 | .union(additionalModifiers) 129 | 130 | if modifiers.contains(.alternate) { 131 | delegate?.terminalViewController(self, send: Data([0x1b, 0x5b])) 132 | } 133 | 134 | if modifiers.contains(.control) { 135 | var firstChar = data.first! 136 | if firstChar >= 97 && firstChar <= 122 { 137 | firstChar -= 32 138 | } 139 | delegate?.terminalViewController(self, send: Data([firstChar ^ 0x40])) 140 | } 141 | else { 142 | delegate?.terminalViewController(self, send: data) 143 | } 144 | } 145 | } 146 | 147 | // MARK: - Terminal view delegate 148 | 149 | extension TerminalViewController: TerminalViewDelegate { 150 | 151 | func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { 152 | delegate?.terminalViewController(self, resizeWithColumns: newCols, rows: newRows) 153 | } 154 | 155 | func setTerminalTitle(source: TerminalView, title: String) {} 156 | 157 | func send(source: TerminalView, data: ArraySlice) { 158 | delegate?.terminalViewController(self, send: Data(data)) 159 | } 160 | 161 | func scrolled(source: TerminalView, position: Double) {} 162 | } 163 | 164 | // MARK: - Input accessory view delegate 165 | 166 | extension TerminalViewController: TerminalInputAccessoryViewDelegate { 167 | 168 | func terminalInputAccessoryView( 169 | _ view: TerminalInputAccessoryView, 170 | didTapKey key: TerminalInputAccessoryView.Key) { 171 | 172 | let sequence: String 173 | switch key { 174 | case .escape: 175 | sequence = "\u{1b}" 176 | case .tab: 177 | sequence = "\t" 178 | case .home: 179 | sequence = "\u{1b}[H" 180 | case .end: 181 | sequence = "\u{1b}[F" 182 | case .arrowLeft: 183 | sequence = "\u{1b}[D" 184 | case .arrowUp: 185 | sequence = "\u{1b}[A" 186 | case .arrowDown: 187 | sequence = "\u{1b}[B" 188 | case .arrowRight: 189 | sequence = "\u{1b}[C" 190 | default: 191 | return 192 | } 193 | 194 | sendTerminalSequence(sequence) 195 | } 196 | } 197 | 198 | // MARK: - Gestures 199 | 200 | extension TerminalViewController: UIGestureRecognizerDelegate { 201 | 202 | @objc private func tapGestureRecognizerCallback() { 203 | guard tapGestureRecognizer.state == .recognized else { return } 204 | guard !terminalView.isFirstResponder else { return } 205 | terminalView.becomeFirstResponder() 206 | } 207 | 208 | @objc func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 209 | !terminalView.isFirstResponder 210 | } 211 | 212 | @objc func gestureRecognizer( 213 | _ gestureRecognizer: UIGestureRecognizer, 214 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { true } 215 | } 216 | 217 | // MARK: - Delegate protocol 218 | 219 | protocol TerminalViewControllerDelegate: AnyObject { 220 | 221 | func terminalViewController(_ viewController: TerminalViewController, 222 | resizeWithColumns columns: Int, 223 | rows: Int) 224 | 225 | func terminalViewController( 226 | _ viewController: TerminalViewController, 227 | send data: Data) 228 | } 229 | -------------------------------------------------------------------------------- /project.yml: -------------------------------------------------------------------------------- 1 | name: TinyEMU-iOS 2 | 3 | packages: 4 | SwiftTerm: 5 | path: Vendors/SwiftTerm 6 | 7 | options: 8 | bundleIdPrefix: com.fernandotcl 9 | deploymentTarget: 10 | iOS: 13.5 11 | usesTabs: false 12 | identWidth: 4 13 | tabWidth: 4 14 | 15 | settings: 16 | DEVELOPMENT_TEAM: LF9WRPC84S 17 | SWIFT_VERSION: 5.2 18 | 19 | targets: 20 | 21 | TinyEMU-iOS: 22 | type: application 23 | platform: iOS 24 | info: 25 | path: Info.plist 26 | properties: 27 | UILaunchStoryboardName: Launch 28 | UISupportedInterfaceOrientations: [ 29 | UIInterfaceOrientationPortrait, 30 | UIInterfaceOrientationLandscapeLeft, 31 | UIInterfaceOrientationLandscapeRight, 32 | ] 33 | UISupportedInterfaceOrientations~ipad: [ 34 | UIInterfaceOrientationPortrait, 35 | UIInterfaceOrientationLandscapeLeft, 36 | UIInterfaceOrientationLandscapeRight, 37 | UIInterfaceOrientationPortraitUpsideDown 38 | ] 39 | UIFileSharingEnabled: true 40 | LSSupportsOpeningDocumentsInPlace: true 41 | 42 | settings: 43 | SWIFT_OBJC_BRIDGING_HEADER: Sources/BridgingHeader.h 44 | OTHER_LDFLAGS: -lresolv -lz 45 | sources: 46 | - Sources 47 | - Launch.storyboard 48 | - Assets/Machine.bundle 49 | dependencies: 50 | - target: TinyEMU 51 | - package: SwiftTerm 52 | 53 | TinyEMU: 54 | productName: libTinyEMU 55 | type: library.static 56 | platform: iOS 57 | postCompileScripts: 58 | - inputFiles: 59 | - $(SRCROOT)/**/*.{c,h} 60 | - $(SRCROOT)/Makefile 61 | outputFiles: 62 | - $(BUILT_PRODUCTS_DIR)/libtemu.a 63 | script: | 64 | set -e 65 | cd "${SRCROOT}"/Vendors/TinyEMU 66 | make clean 67 | if test "x${PLATFORM_NAME}" = xiphoneos; then 68 | make CONFIG_IOS=y CONFIG_FS_NET= CONFIG_SDL= CONFIG_X86EMU= 69 | else 70 | make CONFIG_IOS_SIMULATOR=y CONFIG_FS_NET= CONFIG_SDL= CONFIG_X86EMU= 71 | fi 72 | mkdir -p "${BUILT_PRODUCTS_DIR}"/ 73 | mv libtemu.a "${BUILT_PRODUCTS_DIR}/libTinyEMU.a" 74 | make clean 75 | requiresObjCLinking: true 76 | --------------------------------------------------------------------------------