blob: 386c66d6eac1c841c09aab5cab808afe4827e43e [file] [log] [blame]
// Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
'use strict';
/**
* Constructor a new ConnectDialog instance.
*
* There should only be one of these, and it assumes the connect dialog is
* the only thing in the current window.
*
* @param {!MessagePort} messagePort The HTML5 message port we should use to
* communicate with the nassh instance.
* @constructor
*/
nassh.ConnectDialog = function(messagePort) {
// Message port back to the terminal.
this.messagePort_ = messagePort;
this.messagePort_.onmessage = this.onMessage_.bind(this);
this.messagePort_.start();
// Turn off spellcheck everywhere.
const ary = document.querySelectorAll('input[type="text"]');
for (let i = 0; i < ary.length; i++) {
ary[i].setAttribute('spellcheck', 'false');
}
// The Message Manager instance, null until the messages have loaded.
this.mm_ = null;
// The nassh global pref manager.
this.prefs_ = new nassh.PreferenceManager();
this.localPrefs_ = new nassh.LocalPreferenceManager();
this.prefs_.readStorage(() => {
this.syncProfiles_(this.onPreferencesReady_.bind(this));
this.localPrefs_.readStorage(() => {
this.localPrefs_.syncProfiles(this.prefs_);
});
});
// The profile we're currently displaying.
this.currentProfileRecord_ = null;
// The 'new' profile is special in that it doesn't have a real id or
// prefs object until it is saved for the first time.
this.emptyProfileRecord_ = new nassh.ConnectDialog.ProfileRecord(
'new', null, '[New Connection]');
// Map of id->nassh.ConnectDialog.ProfileRecord.
this.profileMap_ = {};
// Array of nassh.ConnectDialog.ProfileRecord instances in display order.
this.profileList_ = [];
// Cached DOM nodes.
this.form_ = lib.notNull(document.querySelector('form'));
this.moshButton_ = lib.notNull(document.querySelector('#mosh'));
this.mountButton_ = lib.notNull(document.querySelector('#mount'));
this.unmountButton_ = lib.notNull(document.querySelector('#unmount'));
this.sftpClientButton_ = lib.notNull(document.querySelector('#sftp-client'));
this.connectButton_ = lib.notNull(document.querySelector('#connect'));
this.deleteButton_ = lib.notNull(document.querySelector('#delete'));
this.optionsButton_ = lib.notNull(document.querySelector('#options'));
this.feedbackButton_ = lib.notNull(document.querySelector('#feedback'));
};
/**
* Global window message handler, uninstalled after proper handshake.
*
* @param {!Event} e
*/
nassh.ConnectDialog.onWindowMessage = function(e) {
if (e.data.name != 'ipc-init') {
console.warn('Unknown message from terminal:', e.data);
return;
}
window.removeEventListener('message', nassh.ConnectDialog.onWindowMessage);
nassh.loadWebFonts(document);
lib.init().then(() => {
window.dialog_ = new nassh.ConnectDialog(e.data.argv[0].messagePort);
});
};
// Register the message listener.
window.addEventListener('message', nassh.ConnectDialog.onWindowMessage);
/**
* Called by the preference manager when we've retrieved the current preference
* values from storage.
*/
nassh.ConnectDialog.prototype.onPreferencesReady_ = function() {
// Create and draw the shortcut list.
this.shortcutList_ = new nassh.ColumnList(
lib.notNull(document.querySelector('#shortcut-list')),
this.profileList_);
// Install various (DOM and non-DOM) event handlers.
this.installHandlers_();
const lastProfileId = /** @type {string} */ (
this.localPrefs_.get('connectDialog/lastProfileId'));
const profileIndex = lib.f.clamp(
this.getProfileIndex_(lastProfileId), 0, this.profileList_.length);
// Make sure the buttons initial state is sane if we don't switch profiles
// (which refreshes the UI for us).
if (profileIndex === 0) {
this.syncButtons_();
}
this.shortcutList_.setActiveIndex(profileIndex);
// The shortcut list will eventually do this async, but we want it now...
this.setCurrentProfileRecord(this.profileList_[profileIndex]);
nassh.getFileSystem().then(this.onFileSystemFound_.bind(this));
};
/**
* Simple struct to collect data about a profile.
*
* @this {nassh.ConnectDialog}
* @param {string} id
* @param {?lib.PreferenceManager} prefs
* @param {string=} textContent
* @constructor
*/
nassh.ConnectDialog.ProfileRecord = function(id, prefs, textContent) {
this.id = id;
this.prefs = prefs;
this.textContent = textContent || prefs.get('description');
};
/**
* Get a localized message from the Message Manager.
*
* This converts all message name to UPPER_AND_UNDER format, since that's
* pretty handy in the connect dialog.
*
* @this {nassh.ConnectDialog}
* @param {string} name
* @param {!Object=} args
* @return {string}
*/
nassh.ConnectDialog.prototype.msg = function(name, args) {
if (!this.mm_) {
return 'loading...';
}
return this.mm_.get(name.toUpperCase().replace(/-/g, '_'), args);
};
/**
* Align the bottom fields.
*
* We want a grid-like layout for these fields. This is not easily done with
* box layout, but since we're using a fixed width font it's a simple hack.
*/
nassh.ConnectDialog.prototype.alignLabels_ = function() {
const labels = document.querySelectorAll('.aligned-dialog-labels');
let maxWidth = 0;
labels.forEach((el) => maxWidth = Math.max(maxWidth, el.clientWidth));
labels.forEach((el) => el.style.width = `${maxWidth}px`);
};
/**
* Install various event handlers.
*/
nassh.ConnectDialog.prototype.installHandlers_ = function() {
/**
* Small utility to connect DOM events.
*
* @param {!Element} node
* @param {!Array<string>} events
* @param {...function(!Event)} handlers
*/
function addListeners(node, events, ...handlers) {
for (const handler of handlers) {
for (const e of events) {
node.addEventListener(e, handler);
}
}
}
// Observe global 'profile-ids' list so we can keep the ColumnList updated.
this.prefs_.addObservers(null, {
'profile-ids': this.onProfileListChanged_.bind(this),
});
// Same for the 'description' field of all known profiles.
for (let i = 0; i < this.profileList_.length; i++) {
const rec = this.profileList_[i];
if (rec.prefs) {
rec.prefs.addObservers(null, {
description: this.onDescriptionChanged_.bind(this),
});
}
}
// Watch for keypresses sent anywhere in this frame.
document.addEventListener('keydown',
/** @type {!EventListener} */ (this.onDocumentKeyDown_.bind(this)));
// Watch for selection changes on the ColumnList so we can keep the
// 'billboard' updated.
this.shortcutList_.onActiveIndexChanged =
this.onProfileIndexChanged.bind(this);
// Register for keyboard shortcuts on the column list.
this.shortcutList_.addEventListener('keydown',
this.onShortcutListKeyDown_.bind(this));
this.shortcutList_.addEventListener('dblclick',
this.onShortcutListDblClick_.bind(this));
this.form_.addEventListener('keyup', this.onFormKeyUp_.bind(this));
this.connectButton_.addEventListener('keypress',
this.onButtonKeypress_.bind(this));
this.deleteButton_.addEventListener('keypress',
this.onButtonKeypress_.bind(this));
this.moshButton_.addEventListener('click',
this.onMoshClick_.bind(this));
this.mountButton_.addEventListener('click',
this.onMountClick_.bind(this));
this.unmountButton_.addEventListener('click',
this.onUnmountClick_.bind(this));
this.connectButton_.addEventListener('click',
this.onConnectClick_.bind(this));
this.sftpClientButton_.addEventListener('click',
this.onSftpClientClick_.bind(this));
this.deleteButton_.addEventListener('click',
this.onDeleteClick_.bind(this));
this.optionsButton_.addEventListener('click',
this.onOptionsClick_.bind(this));
this.feedbackButton_.addEventListener('click',
this.onFeedbackClick_.bind(this));
// These fields interact with each-other's placeholder text.
['description', 'username', 'hostname', 'port',
].forEach((name) => {
const field = /** @type {!Element} */ (this.$f(name));
// Alter description or detail placeholders, and commit the pref.
addListeners(field, ['change', 'keypress', 'keyup'],
this.updatePlaceholders_.bind(this, name),
this.maybeDirty_.bind(this, name));
addListeners(field, ['focus'],
this.maybeCopyPlaceholder_.bind(this, name));
});
this.$f('description').addEventListener(
'blur', this.maybeCopyPlaceholders_.bind(this));
// These fields are plain text with no fancy properties.
['argstr', 'terminal-profile', 'mount-path',
].forEach((name) => {
addListeners(/** @type {!Element} */ (this.$f(name)),
['change', 'keypress', 'keyup'],
this.maybeDirty_.bind(this, name));
});
['description', 'username', 'hostname', 'port', 'nassh-options',
'identity', 'argstr', 'terminal-profile', 'mount-path',
].forEach((name) => {
addListeners(/** @type {!Element} */ (this.$f(name)), ['focus', 'blur'],
this.onFormFocusChange_.bind(this));
});
// Listen for DEL on the identity select box.
this.$f('identity').addEventListener('keyup', (e) => {
if (e.keyCode == 46 && e.target.selectedIndex != 0) {
this.deleteIdentity_(e.target.value);
}
});
this.importFileInput_ = document.querySelector('#import-file-input');
this.importFileInput_.addEventListener(
'change', this.onImportFiles_.bind(this));
const importLink = document.querySelector('#import-link');
importLink.addEventListener('click', (e) => {
this.importFileInput_.click();
e.preventDefault();
});
};
/**
* Quick way to ask for a '#field-' element from the dom.
*
* @param {string} name
* @param {string=} attrName
* @param {string=} attrValue
* @return {!Element|string|undefined}
*/
nassh.ConnectDialog.prototype.$f = function(name, attrName, attrValue) {
const node = document.querySelector('#field-' + name);
if (!node) {
throw new Error('Can\'t find: #field-' + name);
}
if (!attrName) {
return node;
}
if (typeof attrValue == 'undefined') {
return node.getAttribute(attrName);
}
node.setAttribute(attrName, attrValue);
};
/**
* Change the active profile.
*
* @param {?nassh.ConnectDialog.ProfileRecord} profileRecord
*/
nassh.ConnectDialog.prototype.setCurrentProfileRecord = function(
profileRecord) {
if (!profileRecord) {
throw new Error('null profileRecord.');
}
this.currentProfileRecord_ = profileRecord;
this.syncForm_();
// For console debugging.
window.p_ = profileRecord;
};
/**
* Change the enabled state of one of our <div role='button'> elements.
*
* Since they're not real <button> tags the don't react properly to the
* disabled property.
*
* @param {!Element} button
* @param {boolean} state
*/
nassh.ConnectDialog.prototype.enableButton_ = function(button, state) {
if (state) {
button.removeAttribute('disabled');
button.setAttribute('tabindex', '0');
} else {
button.setAttribute('disabled', 'disabled');
button.setAttribute('tabindex', '-1');
}
};
/**
* Change the display state of one of our <div role='button'> elements.
*
* @param {!Element} button
* @param {boolean} state
* @param {string=} style
*/
nassh.ConnectDialog.prototype.displayButton_ = function(
button, state, style = 'inline') {
if (state) {
button.style.display = style;
button.setAttribute('tabindex', '0');
} else {
button.style.display = 'none';
button.setAttribute('tabindex', '-1');
}
};
/**
* Change the mounted state of the mount button.
*
* @param {boolean} state
*/
nassh.ConnectDialog.prototype.displayMountButton_ = function(state) {
this.displayButton_(
lib.notNull(document.querySelector('#mount-path')),
state,
'flex');
if (!state) {
this.displayButton_(this.mountButton_, false);
this.displayButton_(this.unmountButton_, false);
return;
}
chrome.fileSystemProvider.getAll((fileSystems) => {
for (const fs of fileSystems) {
if (fs.fileSystemId == this.currentProfileRecord_.id) {
this.displayButton_(this.mountButton_, false);
this.displayButton_(this.unmountButton_, true);
return;
}
}
this.displayButton_(this.mountButton_, true);
this.displayButton_(this.unmountButton_, false);
});
};
/**
* Persist the current form to prefs, even if it's invalid.
*/
nassh.ConnectDialog.prototype.save = function() {
if (!this.$f('description').value) {
return;
}
let dirtyForm = false;
const changedFields = {};
let prefs = this.currentProfileRecord_.prefs;
['description', 'username', 'hostname', 'port', 'nassh-options',
'identity', 'argstr', 'terminal-profile', 'mount-path',
].forEach((name) => {
let value = this.$f(name).value;
// Most fields don't make sense with leading or trailing whitespace, so
// trim them automatically. This could cause confusion in some fields
// like the ssh argstr. We leave it in username since it is technically
// valid even if most users would get confused by it.
if (name != 'username') {
value = value.trim();
}
if (name == 'port') {
value = parseInt(value, 10);
if (!value) {
// If parsing failed for any reason, reset it to the default.
value = null;
}
}
if ((!prefs && !value) || (prefs && value == prefs.get(name))) {
return;
}
dirtyForm = true;
changedFields[name] = value;
});
if (dirtyForm) {
if (!prefs) {
prefs = this.prefs_.createProfile();
this.localPrefs_.createProfile(prefs.id);
const rec = new nassh.ConnectDialog.ProfileRecord(
prefs.id, prefs, changedFields['description']);
this.currentProfileRecord_ = rec;
prefs.addObservers(null, {
description: this.onDescriptionChanged_.bind(this),
});
this.shortcutList_.afterNextRedraw(() => {
this.shortcutList_.setActiveIndex(this.profileList_.length - 1);
});
}
for (const name in changedFields) {
this.currentProfileRecord_.prefs.set(name, changedFields[name]);
}
}
};
/**
* Helper for starting a connection.
*
* @param {string} message The message to send to the main window to startup.
* @param {string} proto The URI schema to try and register.
*/
nassh.ConnectDialog.prototype.startup_ = function(message, proto) {
this.maybeCopyPlaceholders_();
this.save();
// Since the user has initiated this connection, register the protocol.
nassh.registerProtocolHandler(proto);
this.localPrefs_.set(
'connectDialog/lastProfileId', this.currentProfileRecord_.id);
if (this.form_.checkValidity()) {
this.postMessage(message, [this.currentProfileRecord_.id]);
} else {
this.form_.reportValidity();
}
};
/**
* Switch to mosh mode.
*/
nassh.ConnectDialog.prototype.mosh = function() {
this.startup_('mosh', 'ssh');
};
/**
* Mount the selected profile.
*/
nassh.ConnectDialog.prototype.mount = function() {
this.startup_('mountProfile', 'ssh');
};
/**
* Unmount the SFTP connection.
*/
nassh.ConnectDialog.prototype.unmount = function() {
nassh.runtimeSendMessage({
command: 'unmount', fileSystemId: this.currentProfileRecord_.id,
})
.then(({error, message}) => {
if (error) {
console.warn(message);
}
// Always refresh button display if internal state changed.
this.displayMountButton_(true);
});
};
/**
* Start a SFTP session with the selected profile.
*/
nassh.ConnectDialog.prototype.sftpConnect = function() {
this.startup_('sftpConnectToProfile', 'sftp');
};
/**
* Connect to the selected profile.
*/
nassh.ConnectDialog.prototype.connect = function() {
this.startup_('connectToProfile', 'ssh');
};
/**
* Send a message back to the terminal.
*
* @param {string} name
* @param {?Object=} argv
*/
nassh.ConnectDialog.prototype.postMessage = function(name, argv = null) {
this.messagePort_.postMessage({name: name, argv: argv});
};
/**
* Set the profile's dirty bit if the given field has changed from it's current
* pref value.
*
* @param {string} fieldName
*/
nassh.ConnectDialog.prototype.maybeDirty_ = function(fieldName) {
if (this.currentProfileRecord_.prefs) {
if (this.$f(fieldName).value !=
this.currentProfileRecord_.prefs.get(fieldName)) {
this.currentProfileRecord_.dirty = true;
}
} else {
if (this.$f(fieldName).value) {
this.currentProfileRecord_.dirty = true;
}
}
};
/**
* Invoke the maybeCopyPlaceholder_ method for the fields we're willing
* to bulk-default.
*/
nassh.ConnectDialog.prototype.maybeCopyPlaceholders_ = function() {
['description', 'username', 'hostname', 'port', 'nassh-options',
].forEach(this.maybeCopyPlaceholder_.bind(this));
this.syncButtons_();
};
/**
* If the field is empty and the current placeholder isn't the default,
* then initialize the field to the placeholder.
*
* @param {string} fieldName
*/
nassh.ConnectDialog.prototype.maybeCopyPlaceholder_ = function(fieldName) {
const field = this.$f(fieldName);
const placeholder = field.getAttribute('placeholder');
if (!field.value && placeholder != this.msg('FIELD_' + fieldName +
'_PLACEHOLDER')) {
field.value = placeholder;
}
};
/**
* Compute the placeholder text for a given field.
*
* @param {string} fieldName
*/
nassh.ConnectDialog.prototype.updatePlaceholders_ = function(fieldName) {
if (fieldName == 'description') {
// If the description changed, update the username/host/etc placeholders.
this.updateDetailPlaceholders_();
} else {
// Otherwise update the description placeholder.
this.updateDescriptionPlaceholder_();
}
// In either case, the hostname might have changed, so cascade updates into
// the nassh options.
this.updateNasshOptionsPlaceholder_();
};
/**
* Update the placeholders in the detail (username, hostname, etc) fields.
*/
nassh.ConnectDialog.prototype.updateDetailPlaceholders_ = function() {
// Try to split the description up into the sub-fields.
// This supports basic user[@hostname[:port]] strings, and the hostname match
// is a best effort will remaining simple.
let ary = this.$f('description').value.match(
/^([^@]+)@([^:@\s]+)?(?:(?::)(\d+))?/);
// Set a blank array if the match failed.
ary = ary || [];
// Remove element 0, the "full match" string.
ary.shift();
// Copy the remaining match elements into the appropriate placeholder
// attribute. Set the default placeholder text from this.str.placeholders
// for any field that was not matched.
['username', 'hostname', 'port',
].forEach((name) => {
let value = ary.shift();
if (!value) {
value = this.msg('FIELD_' + name + '_PLACEHOLDER');
}
this.$f(name, 'placeholder', value);
});
};
/**
* Update the nassh-options placeholder.
*/
nassh.ConnectDialog.prototype.updateNasshOptionsPlaceholder_ = function() {
// Google-specific relay hack. This feels dirty. We can revert this once
// we support managed default configs. http://b/28205376 & related docs.
let value = this.msg('FIELD_NASSH_OPTIONS_PLACEHOLDER');
if (!this.$f('nassh-options').value) {
let hostname = this.$f('hostname').value;
if (!hostname) {
hostname = this.$f('hostname').placeholder;
}
const googleHostRegexp = new RegExp(
'\\.(' +
'corp\\.google\\.com|' +
'c\\.googlers\\.com|' +
'cloud\\.googlecorp\\.com|' +
'(internal|proxy)\\.gcpnode\\.com' +
')$');
if (hostname.match(googleHostRegexp)) {
value = '--config=google';
}
}
this.$f('nassh-options', 'placeholder', value);
};
/**
* Update the description placeholder.
*/
nassh.ConnectDialog.prototype.updateDescriptionPlaceholder_ = function() {
const username = this.$f('username').value;
const hostname = this.$f('hostname').value;
let placeholder;
if (username && hostname) {
placeholder = username + '@' + hostname;
const v = this.$f('port').value;
if (v) {
placeholder += ':' + v;
}
} else {
placeholder = this.msg('FIELD_DESCRIPTION_PLACEHOLDER');
}
this.$f('description', 'placeholder', placeholder);
};
/**
* Sync the form with the current profile record.
*/
nassh.ConnectDialog.prototype.syncForm_ = function() {
['description', 'username', 'hostname', 'port', 'argstr', 'nassh-options',
'identity', 'terminal-profile', 'mount-path',
].forEach((n) => {
const emptyValue = '';
if (this.currentProfileRecord_.prefs) {
this.$f(n).value =
this.currentProfileRecord_.prefs.get(n) || emptyValue;
} else {
this.$f(n).value = emptyValue;
}
});
// If the profile settings point to a key that no longer exists, reset it.
if (this.$f('identity').selectedIndex == -1) {
this.$f('identity').selectedIndex = 0;
}
this.updateDetailPlaceholders_();
this.updateDescriptionPlaceholder_();
};
/**
* Checks whether the current machine can use the File System Provider API, and
* thus be able to be mounted.
*
* @return {boolean}
*/
nassh.ConnectDialog.prototype.checkMountable_ = function() {
return chrome.fileSystemProvider !== undefined;
};
/**
* Sync the states of the buttons.
*/
nassh.ConnectDialog.prototype.syncButtons_ = function() {
this.enableButton_(
this.deleteButton_,
this.shortcutList_.activeIndex != 0);
this.displayMountButton_(this.checkMountable_());
};
/**
* Sync the identity dropdown box with the filesystem.
*
* @param {function()=} onSuccess
* @return {!Promise}
*/
nassh.ConnectDialog.prototype.syncIdentityDropdown_ = function(onSuccess) {
const keyfileNames = new Set();
const identitySelect = this.$f('identity');
let selectedName;
if (this.currentProfileRecord_.prefs) {
selectedName = this.currentProfileRecord_.prefs.get('identity');
} else {
selectedName = identitySelect.value;
}
const onReadError = () => {
const option = document.createElement('option');
option.textContent = 'Error!';
identitySelect.appendChild(option);
};
const onReadSuccess = (entries) => {
// Create a set of the filenames which is all we care about.
const fileNames = new Set(
entries.filter((entry) => entry.isFile).map((entry) => entry.name));
fileNames.forEach((name) => {
const ary = name.match(/^(.*)\.pub/);
if (ary && fileNames.has(ary[1])) {
keyfileNames.add(ary[1]);
} else if (name.startsWith('id_') && !name.endsWith('.pub')) {
keyfileNames.add(name);
}
});
};
const onFinalLoad = () => {
// Reset the list with the current set of keys.
while (identitySelect.firstChild) {
identitySelect.removeChild(identitySelect.firstChild);
}
const option = document.createElement('option');
option.textContent = '[default]';
option.value = '';
identitySelect.appendChild(option);
Array.from(keyfileNames).sort().forEach((keyfileName) => {
const option = document.createElement('option');
const idx = keyfileName.lastIndexOf('/');
const key = keyfileName.substr(idx + 1);
option.textContent = key;
option.value = keyfileName;
identitySelect.appendChild(option);
if (keyfileName == selectedName) {
identitySelect.selectedIndex = identitySelect.length - 1;
}
});
this.syncForm_();
if (onSuccess) {
onSuccess();
}
};
return Promise.all([
// Load legacy/filtered keys from /.ssh/.
// TODO: Delete this at some point after Aug 2019. Jan 2021 should be long
// enough for users to migrate.
lib.fs.readDirectory(this.fileSystem_.root, '/.ssh/')
.then(onReadSuccess).catch(onReadError),
// Load new keys from /.ssh/identity/.
lib.fs.readDirectory(this.fileSystem_.root, '/.ssh/identity/')
.then((entries) => {
entries.forEach((entry) => {
if (entry.isFile && !entry.name.endsWith('-cert.pub')) {
keyfileNames.add(entry.name);
}
});
}),
])
.catch((e) => console.error('Loading keys failed', e))
.finally(onFinalLoad);
};
/**
* Delete one a pair of identity files from the html5 filesystem.
*
* @param {string} identityName
* @return {!Promise}
*/
nassh.ConnectDialog.prototype.deleteIdentity_ = function(identityName) {
const removeFile = (file) => {
// We swallow the rejection because we try to delete paths that are
// often not there (e.g. missing .pub file).
return lib.fs.removeFile(this.fileSystem_.root, file).catch(() => {});
};
const files = [
// Delete the private & public key halves for this identity from the .ssh/
// and .ssh/identity/ dirs. We used to require importing the pub file in
// order to update the display, but that's no longer required. We used to
// import keys into .ssh/, but that made enumeration messy. To migrate from
// the old world state to the new world state, delete all the files! We can
// delete after Jan 2021.
`/.ssh/${identityName}`,
`/.ssh/${identityName}.pub`,
`/.ssh/identity/${identityName}`,
`/.ssh/identity/${identityName}.pub`,
`/.ssh/identity/${identityName}-cert.pub`,
];
return Promise.all(files.map(removeFile))
.finally(() => this.syncIdentityDropdown_());
};
/**
* Delete a profile.
*
* @param {string} deadID ID of profile to delete.
*/
nassh.ConnectDialog.prototype.deleteProfile_ = function(deadID) {
if (this.currentProfileRecord_.id == deadID) {
// The actual profile removal and list-updating will happen async.
// Rather than come up with a fancy hack to update the selection when
// it's done, we just move it before the delete.
const currentIndex = this.shortcutList_.activeIndex;
if (currentIndex == this.profileList_.length - 1) {
// User is deleting the last (non-new) profile, select the one before
// it.
this.shortcutList_.setActiveIndex(this.profileList_.length - 2);
} else {
this.shortcutList_.setActiveIndex(currentIndex + 1);
}
}
this.prefs_.removeProfile(deadID);
};
/**
* Return the index into this.profileList_ for a given profile id.
*
* @param {string} id
* @return {number} -1 if the id is not found.
*/
nassh.ConnectDialog.prototype.getProfileIndex_ = function(id) {
for (let i = 0; i < this.profileList_.length; i++) {
if (this.profileList_[i].id == id) {
return i;
}
}
return -1;
};
/**
* Sync the ColumnList with the known profiles.
*
* @param {function()=} callback
*/
nassh.ConnectDialog.prototype.syncProfiles_ = function(callback) {
const ids = this.prefs_.get('profile-ids');
this.profileList_.length = 0;
let currentProfileExists = false;
let emptyProfileExists = false;
const deadProfiles = Object.keys(this.profileMap_);
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
let p;
if (this.currentProfileRecord_ && id == this.currentProfileRecord_.id) {
currentProfileExists = true;
}
if (id == this.emptyProfileRecord_.id) {
emptyProfileExists = true;
p = this.emptyProfileRecord_;
} else {
p = this.profileMap_[id];
}
deadProfiles.splice(deadProfiles.indexOf(id), 1);
if (!p) {
p = this.profileMap_[id] = new nassh.ConnectDialog.ProfileRecord(
id, this.prefs_.getProfile(id));
} else if (p.prefs) {
p.textContent = p.prefs.get('description');
}
this.profileList_.push(p);
}
for (let i = 0; i < deadProfiles.length; i++) {
delete this.profileMap_[deadProfiles[i]];
}
if (!currentProfileExists) {
this.setCurrentProfileRecord(this.emptyProfileRecord_);
}
if (!emptyProfileExists) {
this.profileList_.unshift(this.emptyProfileRecord_);
this.profileMap_[this.emptyProfileRecord_.id] = this.emptyProfileRecord_;
}
if (this.profileList_.length == 1) {
if (callback) {
callback();
}
}
// Start at 1 for the "[New Connection]" profile.
let initialized = 1;
const onRead = function(profile) {
profile.textContent = profile.prefs.get('description');
if ((++initialized == this.profileList_.length) && callback) {
callback();
}
};
this.profileList_.forEach((profile) => {
if (profile.prefs) {
profile.prefs.readStorage(onRead.bind(this, profile));
}
});
};
/**
* Success callback for lib.fs.getFileSystem().
*
* Kick off the "Identity" dropdown now that we have access to the filesystem.
*
* @param {!FileSystem} fileSystem
*/
nassh.ConnectDialog.prototype.onFileSystemFound_ = function(fileSystem) {
this.fileSystem_ = fileSystem;
this.syncIdentityDropdown_();
// Tell the parent we're ready to roll.
this.postMessage('ipc-init-ok');
};
/**
* User initiated file import.
*
* This is the onChange handler for the `input type="file"`
* (aka this.importFileInput_) control.
*
* @param {!Event} e
* @return {boolean}
*/
nassh.ConnectDialog.prototype.onImportFiles_ = function(e) {
const promises = [];
const input = this.importFileInput_;
// Create promises for all the file imports.
for (let i = 0; i < input.files.length; ++i) {
const file = input.files[i];
// Skip pub key halves as we don't need/use them.
// Except ssh has a naming convention for certificate files.
if (file.name.endsWith('.pub') && !file.name.endsWith('-cert.pub')) {
continue;
}
const targetPath = `/.ssh/identity/${file.name}`;
promises.push(lib.fs.overwriteFile(
this.fileSystem_.root, targetPath, file));
}
// Resolve all the imports before syncing the UI.
Promise.all(promises)
.finally(() => {
// If the import doesn't fully work (skip files/etc...), reset the UI
// back to whatever the user has currently selected.
const select = this.$f('identity');
const selectedIndex = select.selectedIndex;
this.syncIdentityDropdown_(() => {
// Walk all the files the user imported and pick the first valid match.
for (let i = 0; i < input.files.length; ++i) {
select.value = input.files[i].name;
if (select.selectedIndex != -1) {
this.save();
break;
}
}
// Couldn't find anything, so restore the previous value.
if (select.selectedIndex == -1) {
select.selectedIndex = selectedIndex;
}
// Clear the files list so the next import always works.
input.value = '';
});
});
return false;
};
/**
* Keydown event anywhere in this frame.
*
* @param {!KeyboardEvent} e The user keydown event to process.
* @return {boolean} Whether to keep processing the event.
*/
nassh.ConnectDialog.prototype.onDocumentKeyDown_ = function(e) {
const key = String.fromCharCode(e.keyCode);
const lowerKey = key.toLowerCase();
let cancel = false;
// Swallow common shortcuts that don't make sense in this app.
switch (lowerKey) {
// Shortcuts where we kill both the non-shift and shift variants.
case 'n': // New window (!shift) and new incognito window (shift).
case 'p': // Chrome print (!shift) and OS print (shift).
case 'o': // Open (!shift) and bookmark manager (shift).
case 't': // New tab (!shift) and new incognito tab (shift).
if (e.ctrlKey && !e.altKey && !e.metaKey) {
cancel = true;
}
break;
// Shortcuts where we only kill non-shift variants (and allow shift).
case 'j': // Downloads.
case 'h': // History.
case 's': // Save.
case 'u': // View source.
if (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) {
cancel = true;
}
break;
}
if (cancel) {
e.preventDefault();
e.stopPropagation();
return false;
}
return true;
};
/**
* Keydown event on the shortcut list.
*
* @param {!Event} e
*/
nassh.ConnectDialog.prototype.onShortcutListKeyDown_ = function(e) {
const isNewConnection =
this.currentProfileRecord_ == this.emptyProfileRecord_;
if (e.keyCode == 46) {
// DEL delete the profile.
if (!isNewConnection) {
this.deleteProfile_(this.currentProfileRecord_.id);
} else {
// Otherwise the user is deleting the placeholder profile. All we
// do here is reset the form.
this.syncForm_();
this.$f('description').focus();
}
} else if (e.keyCode == 13) {
if (isNewConnection) {
this.$f('description').focus();
} else {
this.onConnectClick_();
}
}
};
/** @param {!Event} e */
nassh.ConnectDialog.prototype.onShortcutListDblClick_ = function(e) {
this.onConnectClick_();
};
/**
* Called when the ColumnList says the active profile changed.
*
* @param {!nassh.ColumnList.ActiveIndexChangedEvent} e
*/
nassh.ConnectDialog.prototype.onProfileIndexChanged = function(e) {
this.setCurrentProfileRecord(this.profileList_[e.now]);
this.syncButtons_();
};
/**
* Key press while a button has focus.
*
* @param {!Event} e
*/
nassh.ConnectDialog.prototype.onButtonKeypress_ = function(e) {
if (e.charCode == 13 || e.charCode == 32) {
e.srcElement.click();
}
};
/**
* Someone clicked on the mosh button.
*/
nassh.ConnectDialog.prototype.onMoshClick_ = function() {
this.mosh();
};
/**
* Someone clicked on the mount button.
*/
nassh.ConnectDialog.prototype.onMountClick_ = function() {
this.mount();
};
/**
* Someone clicked on the unmount button.
*/
nassh.ConnectDialog.prototype.onUnmountClick_ = function() {
this.unmount();
};
/**
* Someone clicked on the connect button.
*/
nassh.ConnectDialog.prototype.onConnectClick_ = function() {
if (this.connectButton_.getAttribute('disabled')) {
return;
}
this.connect();
};
/**
* Someone clicked on the sftp client button.
*
* @param {!Event} e
*/
nassh.ConnectDialog.prototype.onSftpClientClick_ = function(e) {
if (this.sftpClientButton_.getAttribute('disabled')) {
return;
}
this.sftpConnect();
};
/**
* Someone clicked on the delete button.
*
* @param {!Event} e
*/
nassh.ConnectDialog.prototype.onDeleteClick_ = function(e) {
if (this.deleteButton_.getAttribute('disabled')) {
return;
}
if (document.activeElement.getAttribute('id') == 'field-identity') {
this.deleteIdentity_(e.target.value);
} else {
this.deleteProfile_(this.currentProfileRecord_.id);
this.shortcutList_.focus();
}
};
/**
* Someone clicked on the options button.
*/
nassh.ConnectDialog.prototype.onOptionsClick_ = function() {
nassh.openOptionsPage();
};
/**
* Someone clicked on the feedback button.
*/
nassh.ConnectDialog.prototype.onFeedbackClick_ = nassh.sendFeedback;
/**
* KeyUp on the form element.
*
* @param {!Event} e
*/
nassh.ConnectDialog.prototype.onFormKeyUp_ = function(e) {
if (e.keyCode == 13) { // ENTER
this.connect();
} else if (e.keyCode == 27) { // ESC
this.syncForm_();
this.shortcutList_.focus();
}
};
/**
* Focus change on the form element.
*
* This handler is registered to every form element's focus and blur events.
* Keep in mind that for change in focus from one input to another will invoke
* this twice.
*
* @param {!Event} e
*/
nassh.ConnectDialog.prototype.onFormFocusChange_ = function(e) {
this.syncButtons_();
this.save();
};
/**
* Pref callback invoked when the global 'profile-ids' changed.
*/
nassh.ConnectDialog.prototype.onProfileListChanged_ = function() {
this.syncProfiles_(() => this.shortcutList_.redraw());
};
/**
* Pref callback invoked when a profile's description has changed.
*
* @param {*} value
* @param {string} name
* @param {!Object} prefs
*/
nassh.ConnectDialog.prototype.onDescriptionChanged_ = function(
value, name, prefs) {
if (this.profileMap_[prefs.id]) {
this.profileMap_[prefs.id].textContent = value;
this.shortcutList_.scheduleRedraw();
}
};
/**
* Handle a message from the terminal.
*
* @param {!Event} e
*/
nassh.ConnectDialog.prototype.onMessage_ = function(e) {
if (e.data.name in this.onMessageName_) {
this.onMessageName_[e.data.name].apply(this, e.data.argv);
} else {
console.warn('Unhandled message: ' + e.data.name, e.data);
}
};
/**
* Terminal message handlers.
*
* @suppress {lintChecks}
*/
nassh.ConnectDialog.prototype.onMessageName_ = {};
/**
* terminal-info: The terminal introduces itself.
*
* @this {nassh.ConnectDialog}
* @param {!Object} info
*/
nassh.ConnectDialog.prototype.onMessageName_['terminal-info'] = function(info) {
this.mm_ = new lib.MessageManager(info.acceptLanguages);
this.mm_.processI18nAttributes(document.body);
this.updateDetailPlaceholders_();
this.updateDescriptionPlaceholder_();
document.body.style.fontFamily = info.fontFamily;
document.body.style.fontSize = info.fontSize + 'px';
const fg = lib.notNull(lib.colors.normalizeCSS(info.foregroundColor));
const bg = lib.notNull(lib.colors.normalizeCSS(info.backgroundColor));
const cursor = lib.notNull(lib.colors.normalizeCSS(info.cursorColor));
const vars = {
'--nassh-bg-color': bg,
'--nassh-fg-color': fg,
'--nassh-cursor-color': cursor,
};
for (let i = 10; i < 100; i += 5) {
vars['--nassh-bg-color-' + i] = lib.colors.setAlpha(bg, i / 100);
vars['--nassh-fg-color-' + i] = lib.colors.setAlpha(fg, i / 100);
vars['--nassh-cursor-color-' + i] = lib.colors.setAlpha(cursor, i / 100);
}
for (const key in vars) {
if (key.startsWith('--nassh-')) {
document.documentElement.style.setProperty(key, vars[key]);
}
}
// Tell the parent we've finished loading all the terminal details.
this.postMessage('terminal-info-ok');
};
/**
* We're now visible, so do all the things that require visibility.
*
* @this {nassh.ConnectDialog}
*/
nassh.ConnectDialog.prototype.onMessageName_['visible'] = function() {
// Focus the connection dialog.
if (this.profileList_.length == 1) {
// Just one profile record? It's the "New..." profile, focus the form.
this.$f('description').focus();
} else {
this.shortcutList_.focus();
}
// Now that we're visible and can calculate font metrics, align the labels.
this.alignLabels_();
};