ish/app/TerminalViewController.m
Saagar Jha 52e94fd361 Make preference updates thread-safe
Most of the code assumed it was called on the main thread, but we need
to make this explicit now because preference updates will be driven off
the UI as well.
2022-01-23 11:02:11 -08:00

522 lines
20 KiB
Objective-C

//
// ViewController.m
// iSH
//
// Created by Theodore Dubois on 10/17/17.
//
#import "TerminalViewController.h"
#import "AppDelegate.h"
#import "TerminalView.h"
#import "BarButton.h"
#import "ArrowBarButton.h"
#import "UserPreferences.h"
#import "AboutViewController.h"
#import "CurrentRoot.h"
#import "NSObject+SaneKVO.h"
#import "LinuxInterop.h"
#include "kernel/init.h"
#include "kernel/task.h"
#include "kernel/calls.h"
#include "fs/devices.h"
@interface TerminalViewController () <UIGestureRecognizerDelegate>
@property UITapGestureRecognizer *tapRecognizer;
@property (weak, nonatomic) IBOutlet TerminalView *termView;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *bottomConstraint;
@property (weak, nonatomic) IBOutlet UIButton *tabKey;
@property (weak, nonatomic) IBOutlet UIButton *controlKey;
@property (weak, nonatomic) IBOutlet UIButton *escapeKey;
@property (strong, nonatomic) IBOutletCollection(id) NSArray *barButtons;
@property (strong, nonatomic) IBOutletCollection(id) NSArray *barControls;
@property (weak, nonatomic) IBOutlet UIInputView *barView;
@property (weak, nonatomic) IBOutlet UIStackView *bar;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *barTop;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *barBottom;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *barLeading;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *barTrailing;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *barButtonWidth;
@property (weak, nonatomic) IBOutlet UIView *settingsBadge;
@property (weak, nonatomic) IBOutlet UIButton *infoButton;
@property (weak, nonatomic) IBOutlet UIButton *pasteButton;
@property (weak, nonatomic) IBOutlet UIButton *hideKeyboardButton;
@property int sessionPid;
@property (nonatomic) Terminal *sessionTerminal;
@property BOOL ignoreKeyboardMotion;
@property (nonatomic) BOOL hasExternalKeyboard;
@end
@implementation TerminalViewController
- (void)viewDidLoad {
[super viewDidLoad];
#if !ISH_LINUX
int bootError = [AppDelegate bootError];
if (bootError < 0) {
NSString *message = [NSString stringWithFormat:@"could not boot"];
NSString *subtitle = [NSString stringWithFormat:@"error code %d", bootError];
if (bootError == _EINVAL)
subtitle = [subtitle stringByAppendingString:@"\n(try reinstalling the app, see release notes for details)"];
[self showMessage:message subtitle:subtitle];
NSLog(@"boot failed with code %d", bootError);
}
#endif
self.terminal = self.terminal;
[self.termView becomeFirstResponder];
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self
selector:@selector(keyboardDidSomething:)
name:UIKeyboardWillChangeFrameNotification
object:nil];
[center addObserver:self
selector:@selector(keyboardDidSomething:)
name:UIKeyboardDidChangeFrameNotification
object:nil];
[center addObserver:self
selector:@selector(_updateBadge)
name:FsUpdatedNotification
object:nil];
[self _updateStyleFromPreferences:NO];
if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
[self.bar removeArrangedSubview:self.hideKeyboardButton];
[self.hideKeyboardButton removeFromSuperview];
}
if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone) {
self.barView.frame = CGRectMake(0, 0, 100, 48);
} else {
self.barView.frame = CGRectMake(0, 0, 100, 55);
}
// SF Symbols is cool
if (@available(iOS 13, *)) {
[self.infoButton setImage:[UIImage systemImageNamed:@"gear"] forState:UIControlStateNormal];
[self.pasteButton setImage:[UIImage systemImageNamed:@"doc.on.clipboard"] forState:UIControlStateNormal];
[self.hideKeyboardButton setImage:[UIImage systemImageNamed:@"keyboard.chevron.compact.down"] forState:UIControlStateNormal];
[self.tabKey setTitle:nil forState:UIControlStateNormal];
[self.tabKey setImage:[UIImage systemImageNamed:@"arrow.right.to.line.alt"] forState:UIControlStateNormal];
[self.controlKey setTitle:nil forState:UIControlStateNormal];
[self.controlKey setImage:[UIImage systemImageNamed:@"control"] forState:UIControlStateNormal];
[self.escapeKey setTitle:nil forState:UIControlStateNormal];
[self.escapeKey setImage:[UIImage systemImageNamed:@"escape"] forState:UIControlStateNormal];
}
[UserPreferences.shared observe:@[@"hideStatusBar"] options:0 owner:self usingBlock:^(typeof(self) self) {
dispatch_async(dispatch_get_main_queue(), ^{
[self setNeedsStatusBarAppearanceUpdate];
});
}];
[UserPreferences.shared observe:@[@"theme", @"hideExtraKeysWithExternalKeyboard"]
options:0 owner:self usingBlock:^(typeof(self) self) {
dispatch_async(dispatch_get_main_queue(), ^{
[self _updateStyleFromPreferences:YES];
});
}];
[self _updateBadge];
}
- (void)awakeFromNib {
[super awakeFromNib];
#if !ISH_LINUX
[NSNotificationCenter.defaultCenter addObserver:self
selector:@selector(processExited:)
name:ProcessExitedNotification
object:nil];
#else
[NSNotificationCenter.defaultCenter addObserver:self
selector:@selector(kernelPanicked:)
name:KernelPanicNotification
object:nil];
#endif
}
- (void)viewDidAppear:(BOOL)animated {
[AppDelegate maybePresentStartupMessageOnViewController:self];
[super viewDidAppear:animated];
}
- (void)startNewSession {
int err = [self startSession];
if (err < 0) {
[self showMessage:@"could not start session"
subtitle:[NSString stringWithFormat:@"error code %d", err]];
}
}
- (void)reconnectSessionFromTerminalUUID:(NSUUID *)uuid {
self.sessionTerminal = [Terminal terminalWithUUID:uuid];
if (self.sessionTerminal == nil)
[self startNewSession];
}
- (NSUUID *)sessionTerminalUUID {
return self.terminal.uuid;
}
- (int)startSession {
NSArray<NSString *> *command = UserPreferences.shared.launchCommand;
#if !ISH_LINUX
int err = become_new_init_child();
if (err < 0)
return err;
struct tty *tty;
self.sessionTerminal = nil;
Terminal *terminal = [Terminal createPseudoTerminal:&tty];
if (terminal == nil) {
NSAssert(IS_ERR(tty), @"tty should be error");
return (int) PTR_ERR(tty);
}
self.sessionTerminal = terminal;
NSString *stdioFile = [NSString stringWithFormat:@"/dev/pts/%d", tty->num];
err = create_stdio(stdioFile.fileSystemRepresentation, TTY_PSEUDO_SLAVE_MAJOR, tty->num);
if (err < 0)
return err;
tty_release(tty);
char argv[4096];
[Terminal convertCommand:command toArgs:argv limitSize:sizeof(argv)];
const char *envp = "TERM=xterm-256color\0";
err = do_execve(command[0].UTF8String, command.count, argv, envp);
if (err < 0)
return err;
self.sessionPid = current->pid;
task_start(current);
#else
const char *argv_arr[command.count + 1];
for (NSUInteger i = 0; i < command.count; i++)
argv_arr[i] = command[i].UTF8String;
argv_arr[command.count] = NULL;
const char *envp_arr[] = {
"TERM=xterm-256color",
NULL,
};
const char *const *argv = argv_arr;
const char *const *envp = envp_arr;
__block Terminal *terminal = nil;
__block int sessionPid = 0;
__block int err = 1;
sync_do_in_workqueue(^(void (^done)(void)) {
linux_start_session(argv[0], argv, envp, ^(int retval, int pid, nsobj_t term) {
err = retval;
if (term)
terminal = CFBridgingRelease(term);
sessionPid = pid;
done();
});
});
NSAssert(err <= 0, @"session start did not finish??");
if (err < 0)
return err;
self.sessionTerminal = terminal;
self.sessionPid = sessionPid;
#endif
return 0;
}
#if !ISH_LINUX
- (void)processExited:(NSNotification *)notif {
int pid = [notif.userInfo[@"pid"] intValue];
if (pid != self.sessionPid)
return;
[self.sessionTerminal destroy];
// On iOS 13, there are multiple windows, so just close this one.
if (@available(iOS 13, *)) {
// On iPhone, destroying scenes will fail, but the error doesn't actually go to the error handler, which is really stupid. Apple doesn't fix bugs, so I'm forced to just add a check here.
if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad && self.sceneSession != nil) {
[UIApplication.sharedApplication requestSceneSessionDestruction:self.sceneSession options:nil errorHandler:^(NSError *error) {
NSLog(@"scene destruction error %@", error);
self.sceneSession = nil;
[self processExited:notif];
}];
return;
}
}
current = NULL; // it's been freed
[self startNewSession];
}
#endif
#if ISH_LINUX
- (void)kernelPanicked:(NSNotification *)notif {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"panik" message:notif.userInfo[@"message"] preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"k" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:alert animated:YES completion:nil];
}
#endif
- (void)showMessage:(NSString *)message subtitle:(NSString *)subtitle {
dispatch_async(dispatch_get_main_queue(), ^{
UIAlertController *alert = [UIAlertController alertControllerWithTitle:message message:subtitle preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"k"
style:UIAlertActionStyleDefault
handler:nil]];
[self presentViewController:alert animated:YES completion:nil];
});
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (object == [UserPreferences shared]) {
[self _updateStyleFromPreferences:YES];
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)_updateStyleFromPreferences:(BOOL)animated {
NSAssert(NSThread.isMainThread, @"This method needs to be called on the main thread");
NSTimeInterval duration = animated ? 0.1 : 0;
[UIView animateWithDuration:duration animations:^{
self.view.backgroundColor = UserPreferences.shared.theme.backgroundColor;
UIKeyboardAppearance keyAppearance = UserPreferences.shared.theme.keyboardAppearance;
self.termView.keyboardAppearance = keyAppearance;
for (BarButton *button in self.barButtons) {
button.keyAppearance = keyAppearance;
}
UIColor *tintColor = keyAppearance == UIKeyboardAppearanceLight ? UIColor.blackColor : UIColor.whiteColor;
for (UIControl *control in self.barControls) {
control.tintColor = tintColor;
}
}];
UIView *oldBarView = self.termView.inputAccessoryView;
if (UserPreferences.shared.hideExtraKeysWithExternalKeyboard && self.hasExternalKeyboard) {
self.termView.inputAccessoryView = nil;
} else {
self.termView.inputAccessoryView = self.barView;
}
if (self.termView.inputAccessoryView != oldBarView && self.termView.isFirstResponder) {
self.ignoreKeyboardMotion = YES; // avoid infinite recursion
[self.termView reloadInputViews];
self.ignoreKeyboardMotion = NO;
}
}
- (void)_updateStyleAnimated {
[self _updateStyleFromPreferences:YES];
}
- (void)_updateBadge {
self.settingsBadge.hidden = !FsNeedsRepositoryUpdate();
}
- (UIStatusBarStyle)preferredStatusBarStyle {
return UserPreferences.shared.theme.statusBarStyle;
}
- (BOOL)prefersStatusBarHidden {
return UserPreferences.shared.hideStatusBar;
}
- (void)keyboardDidSomething:(NSNotification *)notification {
if (self.ignoreKeyboardMotion)
return;
CGRect keyboardFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
if (CGRectEqualToRect(keyboardFrame, CGRectZero))
return;
NSLog(@"%@ %@", notification.name, [NSValue valueWithCGRect:keyboardFrame]);
self.hasExternalKeyboard = keyboardFrame.size.height < 100;
CGFloat pad = UIScreen.mainScreen.bounds.size.height - keyboardFrame.origin.y;
if (pad != keyboardFrame.size.height) {
pad = 0; // keyboard is not right at the bottom of the screen, must be floating or something
}
if (pad == 0) {
pad = self.view.safeAreaInsets.bottom;
}
// NSLog(@"pad %f", pad);
self.bottomConstraint.constant = pad;
BOOL initialLayout = self.termView.needsUpdateConstraints;
[self.view setNeedsUpdateConstraints];
if (!initialLayout) {
// if initial layout hasn't happened yet, the terminal view is going to be at a really weird place, so animating it is going to look really bad
NSNumber *interval = notification.userInfo[UIKeyboardAnimationDurationUserInfoKey];
NSNumber *curve = notification.userInfo[UIKeyboardAnimationCurveUserInfoKey];
[UIView animateWithDuration:interval.doubleValue
delay:0
options:curve.integerValue << 16
animations:^{
[self.view layoutIfNeeded];
}
completion:nil];
}
}
- (void)setHasExternalKeyboard:(BOOL)hasExternalKeyboard {
_hasExternalKeyboard = hasExternalKeyboard;
[self _updateStyleFromPreferences:YES];
}
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
if ([segue.identifier isEqualToString:@"embed"]) {
// You might want to check if this is your embed segue here
// in case there are other segues triggered from this view controller.
segue.destinationViewController.view.translatesAutoresizingMaskIntoConstraints = NO;
}
}
#pragma mark Bar
- (IBAction)showAbout:(id)sender {
UINavigationController *navigationController = [[UIStoryboard storyboardWithName:@"About" bundle:nil] instantiateInitialViewController];
if ([sender isKindOfClass:[UIGestureRecognizer class]]) {
UIGestureRecognizer *recognizer = sender;
if (recognizer.state == UIGestureRecognizerStateBegan) {
AboutViewController *aboutViewController = (AboutViewController *) navigationController.topViewController;
aboutViewController.includeDebugPanel = YES;
} else {
return;
}
}
[self presentViewController:navigationController animated:YES completion:nil];
[self.termView resignFirstResponder];
}
- (void)resizeBar {
CGSize screen = UIScreen.mainScreen.bounds.size;
CGSize bar = self.barView.bounds.size;
// set sizing parameters on bar
// numbers stolen from iVim and modified somewhat
if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone) {
// phone
[self setBarHorizontalPadding:6 verticalPadding:6 buttonWidth:32];
} else if (bar.width == screen.width || bar.width == screen.height) {
// full-screen ipad
[self setBarHorizontalPadding:15 verticalPadding:8 buttonWidth:43];
} else if (bar.width <= 320) {
// slide over
[self setBarHorizontalPadding:8 verticalPadding:8 buttonWidth:26];
} else {
// split view
[self setBarHorizontalPadding:10 verticalPadding:8 buttonWidth:36];
}
[UIView performWithoutAnimation:^{
[self.barView layoutIfNeeded];
}];
}
- (void)setBarHorizontalPadding:(CGFloat)horizontal verticalPadding:(CGFloat)vertical buttonWidth:(CGFloat)buttonWidth {
self.barLeading.constant = self.barTrailing.constant = horizontal;
self.barTop.constant = self.barBottom.constant = vertical;
self.barButtonWidth.constant = buttonWidth;
}
- (IBAction)pressEscape:(id)sender {
[self pressKey:@"\x1b"];
}
- (IBAction)pressTab:(id)sender {
[self pressKey:@"\t"];
}
- (void)pressKey:(NSString *)key {
[self.termView insertText:key];
}
- (IBAction)pressControl:(id)sender {
self.controlKey.selected = !self.controlKey.selected;
}
- (IBAction)pressArrow:(ArrowBarButton *)sender {
switch (sender.direction) {
case ArrowUp: [self pressKey:[self.terminal arrow:'A']]; break;
case ArrowDown: [self pressKey:[self.terminal arrow:'B']]; break;
case ArrowLeft: [self pressKey:[self.terminal arrow:'D']]; break;
case ArrowRight: [self pressKey:[self.terminal arrow:'C']]; break;
case ArrowNone: break;
}
}
- (void)switchTerminal:(UIKeyCommand *)sender {
unsigned i = (unsigned) sender.input.integerValue;
if (i == 7)
self.terminal = self.sessionTerminal;
else
self.terminal = [Terminal terminalWithType:TTY_CONSOLE_MAJOR number:i];
}
- (void)increaseFontSize:(UIKeyCommand *)command {
self.termView.overrideFontSize = self.termView.effectiveFontSize + 1;
}
- (void)decreaseFontSize:(UIKeyCommand *)command {
self.termView.overrideFontSize = self.termView.effectiveFontSize - 1;
}
- (void)resetFontSize:(UIKeyCommand *)command {
self.termView.overrideFontSize = 0;
}
- (NSArray<UIKeyCommand *> *)keyCommands {
static NSMutableArray<UIKeyCommand *> *commands = nil;
if (commands == nil) {
commands = [NSMutableArray new];
for (unsigned i = 1; i <= 7; i++) {
[commands addObject:
[UIKeyCommand keyCommandWithInput:[NSString stringWithFormat:@"%d", i]
modifierFlags:UIKeyModifierCommand|UIKeyModifierAlternate|UIKeyModifierShift
action:@selector(switchTerminal:)]];
}
[commands addObject:
[UIKeyCommand keyCommandWithInput:@"+"
modifierFlags:UIKeyModifierCommand
action:@selector(increaseFontSize:)
discoverabilityTitle:@"Increase Font Size"]];
[commands addObject:
[UIKeyCommand keyCommandWithInput:@"="
modifierFlags:UIKeyModifierCommand
action:@selector(increaseFontSize:)]];
[commands addObject:
[UIKeyCommand keyCommandWithInput:@"-"
modifierFlags:UIKeyModifierCommand
action:@selector(decreaseFontSize:)
discoverabilityTitle:@"Decrease Font Size"]];
[commands addObject:
[UIKeyCommand keyCommandWithInput:@"0"
modifierFlags:UIKeyModifierCommand
action:@selector(resetFontSize:)
discoverabilityTitle:@"Reset Font Size"]];
[commands addObject:
[UIKeyCommand keyCommandWithInput:@","
modifierFlags:UIKeyModifierCommand
action:@selector(showAbout:)
discoverabilityTitle:@"Settings"]];
}
return commands;
}
- (void)setTerminal:(Terminal *)terminal {
_terminal = terminal;
self.termView.terminal = self.terminal;
}
- (void)setSessionTerminal:(Terminal *)sessionTerminal {
if (_terminal == _sessionTerminal)
self.terminal = sessionTerminal;
_sessionTerminal = sessionTerminal;
}
@end
@interface BarView : UIInputView
@property (weak) IBOutlet TerminalViewController *terminalViewController;
@property (nonatomic) IBInspectable BOOL allowsSelfSizing;
@end
@implementation BarView
@dynamic allowsSelfSizing;
- (void)layoutSubviews {
[self.terminalViewController resizeBar];
}
@end