// // 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 () @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 NSLayoutConstraint *barHeight; @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.barHeight.constant = 36; } else { self.barHeight.constant = 43; } // 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:@[@"colorScheme", @"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 *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 = [[UIColor alloc] ish_initWithHexString:UserPreferences.shared.palette.backgroundColor]; UIKeyboardAppearance keyAppearance = UserPreferences.shared.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) { dispatch_async(dispatch_get_main_queue(), ^{ 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.statusBarStyle; } - (BOOL)prefersStatusBarHidden { return UserPreferences.shared.hideStatusBar; } - (void)keyboardDidSomething:(NSNotification *)notification { if (self.ignoreKeyboardMotion) return; CGRect screenKeyboardFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; UIScreen *screen = UIScreen.mainScreen; // notification.object is nil before iOS 16.1 and the correct UIScreen after iOS 16.1 if (notification.object != nil) screen = notification.object; CGRect keyboardFrame = [self.view convertRect:screenKeyboardFrame fromCoordinateSpace:screen.coordinateSpace]; if (CGRectEqualToRect(keyboardFrame, CGRectZero)) return; CGRect intersection = CGRectIntersection(keyboardFrame, self.view.bounds); keyboardFrame = intersection; NSLog(@"%@ %@", notification.name, @(keyboardFrame)); self.hasExternalKeyboard = keyboardFrame.size.height < 100; CGFloat pad = CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(keyboardFrame); // The keyboard appears to be undocked. This means it can either be split or // truly floating. In the former case we want to keep the pad, but in the // latter we should fall back to the input accessory view instead of the // keyboard. if (pad != keyboardFrame.size.height && keyboardFrame.size.width != UIScreen.mainScreen.bounds.size.width) { pad = MAX(self.view.safeAreaInsets.bottom, self.termView.inputAccessoryView.frame.size.height); } // 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; } } - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { // Hack to resolve a layering mismatch between the UI and preferences. if (@available(iOS 12.0, *)) { if (previousTraitCollection.userInterfaceStyle != self.traitCollection.userInterfaceStyle) { // Ensure that the relevant things listening for this will update. UserPreferences.shared.colorScheme = UserPreferences.shared.colorScheme; } } } #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 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 >= 450) { // wide ipad [self setBarHorizontalPadding:15 verticalPadding:8 buttonWidth:43]; } else { // narrow ipad (slide over) [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 *)keyCommands { static NSMutableArray *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