From 69115fb77acd2fe86125bbadabe38bf0fef400d1 Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Sat, 24 Jul 2021 16:31:32 +0200 Subject: [PATCH] experimental config sync --- tabby-core/src/configDefaults.yaml | 1 + tabby-core/src/index.ts | 8 +- tabby-core/src/services/config.service.ts | 7 +- tabby-electron/src/index.ts | 4 +- .../pluginsSettingsTab.component.ts | 2 +- .../configSyncSettingsTab.component.pug | 116 ++++++++++++ .../configSyncSettingsTab.component.ts | 99 ++++++++++ .../src/components/settingsTab.component.pug | 8 +- tabby-settings/src/config.ts | 14 +- tabby-settings/src/index.ts | 12 +- .../src/services/configSync.service.ts | 179 ++++++++++++++++++ tabby-settings/src/settings.ts | 21 ++ tabby-terminal/src/settings.ts | 2 +- 13 files changed, 457 insertions(+), 16 deletions(-) create mode 100644 tabby-settings/src/components/configSyncSettingsTab.component.pug create mode 100644 tabby-settings/src/components/configSyncSettingsTab.component.ts create mode 100644 tabby-settings/src/services/configSync.service.ts diff --git a/tabby-core/src/configDefaults.yaml b/tabby-core/src/configDefaults.yaml index 3a498025..875b9f3f 100644 --- a/tabby-core/src/configDefaults.yaml +++ b/tabby-core/src/configDefaults.yaml @@ -31,3 +31,4 @@ enableAutomaticUpdates: true version: 1 vault: null encrypted: false +enableExperimentalFeatures: false diff --git a/tabby-core/src/index.ts b/tabby-core/src/index.ts index 197de0d5..b58e3335 100644 --- a/tabby-core/src/index.ts +++ b/tabby-core/src/index.ts @@ -136,9 +136,11 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex profilesService: ProfilesService, ) { app.ready$.subscribe(() => { - if (config.store.enableWelcomeTab) { - app.openNewTabRaw({ type: WelcomeTabComponent }) - } + config.ready$.toPromise().then(() => { + if (config.store.enableWelcomeTab) { + app.openNewTabRaw({ type: WelcomeTabComponent }) + } + }) }) platform.setErrorHandler(err => { diff --git a/tabby-core/src/services/config.service.ts b/tabby-core/src/services/config.service.ts index e0883cb8..730d6f0d 100644 --- a/tabby-core/src/services/config.service.ts +++ b/tabby-core/src/services/config.service.ts @@ -194,7 +194,6 @@ export class ConfigService { } async save (): Promise { - this.store.__cleanup() // Scrub undefined values let cleanStore = JSON.parse(JSON.stringify(this._store)) cleanStore = await this.maybeEncryptConfig(cleanStore) @@ -238,7 +237,7 @@ export class ConfigService { const module = imp.ngModule || imp if (module.ɵinj?.providers) { this.servicesCache[module.pluginName] = module.ɵinj.providers.map(provider => { - return provider.useClass || provider + return provider.useClass ?? provider.useExisting ?? provider }) } } @@ -382,10 +381,12 @@ export class ConfigService { } delete decryptedVault.config.vault delete decryptedVault.config.encrypted + delete decryptedVault.config.configSync return { ...decryptedVault.config, vault: store.vault, encrypted: store.encrypted, + configSync: store.configSync, } } @@ -400,9 +401,11 @@ export class ConfigService { vault.config = { ...store } delete vault.config.vault delete vault.config.encrypted + delete vault.config.configSync return { vault: await this.vault.encrypt(vault), encrypted: true, + configSync: store.configSync, } } } diff --git a/tabby-electron/src/index.ts b/tabby-electron/src/index.ts index 01ce6cc0..fa66519a 100644 --- a/tabby-electron/src/index.ts +++ b/tabby-electron/src/index.ts @@ -12,7 +12,7 @@ import { ElectronHostWindow } from './services/hostWindow.service' import { ElectronFileProvider } from './services/fileProvider.service' import { ElectronHostAppService } from './services/hostApp.service' import { ElectronService } from './services/electron.service' -import { ElectronHotkeyProvider } from './hotkeys' +// import { ElectronHotkeyProvider } from './hotkeys' import { ElectronConfigProvider } from './config' @NgModule({ @@ -24,7 +24,7 @@ import { ElectronConfigProvider } from './config' { provide: LogService, useClass: ElectronLogService }, { provide: UpdaterService, useClass: ElectronUpdaterService }, { provide: DockingService, useClass: ElectronDockingService }, - { provide: HotkeyProvider, useClass: ElectronHotkeyProvider, multi: true }, + // { provide: HotkeyProvider, useClass: ElectronHotkeyProvider, multi: true }, { provide: ConfigProvider, useClass: ElectronConfigProvider, multi: true }, { provide: FileProvider, useClass: ElectronFileProvider, multi: true }, ], diff --git a/tabby-plugin-manager/src/components/pluginsSettingsTab.component.ts b/tabby-plugin-manager/src/components/pluginsSettingsTab.component.ts index 045a8439..b55b0066 100644 --- a/tabby-plugin-manager/src/components/pluginsSettingsTab.component.ts +++ b/tabby-plugin-manager/src/components/pluginsSettingsTab.component.ts @@ -8,7 +8,7 @@ import { PluginManagerService } from '../services/pluginManager.service' enum BusyState { Installing = 'Installing', Uninstalling = 'Uninstalling' } -const FORCE_ENABLE = ['tabby-core', 'tabby-settings'] +const FORCE_ENABLE = ['tabby-core', 'tabby-settings', 'tabby-electron', 'tabby-web'] /** @hidden */ @Component({ diff --git a/tabby-settings/src/components/configSyncSettingsTab.component.pug b/tabby-settings/src/components/configSyncSettingsTab.component.pug new file mode 100644 index 00000000..b6b5539f --- /dev/null +++ b/tabby-settings/src/components/configSyncSettingsTab.component.pug @@ -0,0 +1,116 @@ +h3.mb-3 Config sync + +ul.nav-tabs(ngbNav, #nav='ngbNav') + li(ngbNavItem) + a(ngbNavLink) Sync + ng-template(ngbNavContent) + .form-line + .header + .title Sync host + + input.form-control( + type='text', + [(ngModel)]='config.store.configSync.host', + (ngModelChange)='config.save()', + ) + + .form-line + .header + .title Secret sync token + .description Get it from the Tabby Web settings window + + .input-group + input.form-control( + type='password', + [(ngModel)]='config.store.configSync.token', + (ngModelChange)='config.save(); testConnection()' + ) + .input-group-append(*ngIf='config.store.configSync.token') + .input-group-text + i.fas.fa-fw.fa-circle-notch.fa-spin.text-warning(*ngIf='connectionSuccessful === null') + i.fas.fa-fw.fa-check.text-success(*ngIf='connectionSuccessful') + i.fas.fa-fw.fa-exclamation-triangle.text-danger(*ngIf='connectionSuccessful === false') + + ng-container(*ngIf='config.store.configSync.token') + .alert.alert-danger(*ngIf='connectionSuccessful === false') + i.fas.fa-exclamation-triangle + span.ml-2 Connection failed: {{connectionError}} + + ng-container(*ngIf='connectionSuccessful') + .form-line + .header + .title Configs + + div(*ngIf='configs === null') + i.fas.fa-fw.fa-circle-notch.fa-spin + span.ml-2 Loading configs... + + ng-container(*ngIf='configs !== null') + .list-group-light + .list-group-item.d-flex.align-items-center( + *ngFor='let cfg of configs', + [class.active]='cfg.id === config.store.configSync.configID', + ) + i.fas.fa-fw.fa-file + .ml-2.d-flex.flex-column.align-items-start + div {{cfg.name}} + small.text-muted Modified on {{cfg.modified_at|date:'medium'}} + .badge.badge-info(*ngIf='cfg.id === config.store.configSync.configID') ACTIVE + .mr-auto + button.btn.btn-link.ml-1( + (click)='uploadAndSync(cfg)', + [class.hover-reveal]='cfg.id !== config.store.configSync.configID' + ) + i.fas.fa-arrow-up + span.ml-2 Upload + button.btn.btn-link.ml-1( + (click)='downloadAndSync(cfg)', + [class.hover-reveal]='cfg.id !== config.store.configSync.configID' + ) + i.fas.fa-arrow-down + span.ml-2 Download + a.list-group-item.list-group-item-action.d-flex.align-items-center( + href='#', + (click)='uploadAsNew()' + ) + i.fas.fa-fw.fa-cloud-upload-alt + .ml-2 Upload as a new config + + ng-container(*ngIf='config.store.configSync.configID') + .form-line + .header + .title Sync automatically + + toggle( + [(ngModel)]='config.store.configSync.auto', + (ngModelChange)='config.save()', + ) + + li(ngbNavItem) + a(ngbNavLink) Advanced + ng-template(ngbNavContent) + .form-line + .header + .title Sync hotkeys + toggle( + [(ngModel)]='config.store.configSync.parts.hotkeys', + (ngModelChange)='config.save()', + ) + + .form-line + .header + .title Sync window settings + toggle( + [(ngModel)]='config.store.configSync.parts.appearance', + (ngModelChange)='config.save()', + ) + + .form-line + .header + .title Sync Vault + toggle( + [(ngModel)]='config.store.configSync.parts.vault', + (ngModelChange)='config.save()', + ) + +div([ngbNavOutlet]='nav') diff --git a/tabby-settings/src/components/configSyncSettingsTab.component.ts b/tabby-settings/src/components/configSyncSettingsTab.component.ts new file mode 100644 index 00000000..7532a8d1 --- /dev/null +++ b/tabby-settings/src/components/configSyncSettingsTab.component.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { Component } from '@angular/core' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { BaseComponent, ConfigService, PromptModalComponent, HostAppService, PlatformService, NotificationsService } from 'tabby-core' +import { Config, ConfigSyncService } from '../services/configSync.service' + + +/** @hidden */ +@Component({ + selector: 'config-sync-settings-tab', + template: require('./configSyncSettingsTab.component.pug'), +}) +export class ConfigSyncSettingsTabComponent extends BaseComponent { + connectionSuccessful: boolean|null = null + connectionError: Error|null = null + configs: Config[]|null = null + + constructor ( + public config: ConfigService, + private configSync: ConfigSyncService, + private hostApp: HostAppService, + private ngbModal: NgbModal, + private platform: PlatformService, + private notifications: NotificationsService, + ) { + super() + } + + async ngOnInit () { + await this.testConnection() + this.loadConfigs() + } + + async testConnection () { + if (!this.config.store.configSync.host || !this.config.store.configSync.token) { + return + } + this.connectionSuccessful = null + try { + await this.configSync.getUser() + this.connectionSuccessful = true + this.loadConfigs() + } catch (e) { + this.connectionSuccessful = false + this.connectionError = e + this.configs = null + } + } + + async loadConfigs () { + this.configs = await this.configSync.getConfigs() + } + + async uploadAsNew () { + let name = `New config on ${this.hostApp.platform}` + const modal = this.ngbModal.open(PromptModalComponent) + modal.componentInstance.prompt = 'Name for the new config' + modal.componentInstance.value = name + name = (await modal.result)?.value + if (!name) { + return + } + const cfg = await this.configSync.createNewConfig(name) + this.loadConfigs() + this.configSync.setConfig(cfg) + this.uploadAndSync(cfg) + } + + async uploadAndSync (cfg: Config) { + if (this.config.store.configSync.configID !== cfg.id) { + if ((await this.platform.showMessageBox({ + type: 'warning', + message: 'Overwrite the config on the remote side and start syncing?', + buttons: ['Overwrite remote and sync', 'Cancel'], + defaultId: 1, + })).response === 1) { + return + } + } + this.configSync.setConfig(cfg) + await this.configSync.upload() + this.loadConfigs() + this.notifications.info('Config uploaded') + } + + async downloadAndSync (cfg: Config) { + if ((await this.platform.showMessageBox({ + type: 'warning', + message: 'Overwrite the local config and start syncing?', + buttons: ['Overwrite local and sync', 'Cancel'], + defaultId: 1, + })).response === 1) { + return + } + this.configSync.setConfig(cfg) + await this.configSync.download() + this.notifications.info('Config downloaded') + } +} diff --git a/tabby-settings/src/components/settingsTab.component.pug b/tabby-settings/src/components/settingsTab.component.pug index 9e9e1154..2abd916c 100644 --- a/tabby-settings/src/components/settingsTab.component.pug +++ b/tabby-settings/src/components/settingsTab.component.pug @@ -15,15 +15,15 @@ button.btn.btn-warning.btn-block(*ngIf='config.restartRequested', '(click)'='res .text-muted {{homeBase.appVersion}} .mb-5.mt-3 - button.btn.btn-secondary.mr-3((click)='homeBase.openGitHub()') + button.btn.btn-secondary.mr-3.mb-2((click)='homeBase.openGitHub()') i.fab.fa-github span GitHub - button.btn.btn-secondary.mr-3((click)='homeBase.reportBug()') + button.btn.btn-secondary.mr-3.mb-2((click)='homeBase.reportBug()') i.fas.fa-bug span Report a problem - button.btn.btn-secondary.mr-3( + button.btn.btn-secondary.mr-3.mb-2( (click)='showReleaseNotes()', ) i.fas.fa-book @@ -90,7 +90,7 @@ button.btn.btn-warning.btn-block(*ngIf='config.restartRequested', '(click)'='res .d-flex.flex-column.w-100.h-100 .h-100.d-flex .w-100.d-flex.flex-column - h3 Config File + h3 Config file textarea.form-control.h-100( [(ngModel)]='configFile' ) diff --git a/tabby-settings/src/config.ts b/tabby-settings/src/config.ts index 553f2bb3..7bdb7301 100644 --- a/tabby-settings/src/config.ts +++ b/tabby-settings/src/config.ts @@ -2,7 +2,19 @@ import { ConfigProvider, Platform } from 'tabby-core' /** @hidden */ export class SettingsConfigProvider extends ConfigProvider { - defaults = { } + defaults = { + configSync: { + host: 'https://tabby.sh', + token: '', + configID: null, + auto: false, + parts: { + hotkeys: true, + appearance: true, + vault: true, + }, + }, + } platformDefaults = { [Platform.macOS]: { hotkeys: { diff --git a/tabby-settings/src/index.ts b/tabby-settings/src/index.ts index c3b0f8bc..b34090c1 100644 --- a/tabby-settings/src/index.ts +++ b/tabby-settings/src/index.ts @@ -17,12 +17,15 @@ import { VaultSettingsTabComponent } from './components/vaultSettingsTab.compon import { SetVaultPassphraseModalComponent } from './components/setVaultPassphraseModal.component' import { ProfilesSettingsTabComponent } from './components/profilesSettingsTab.component' import { ReleaseNotesComponent } from './components/releaseNotesTab.component' +import { ConfigSyncSettingsTabComponent } from './components/configSyncSettingsTab.component' + +import { ConfigSyncService } from './services/configSync.service' import { SettingsTabProvider } from './api' import { ButtonProvider } from './buttonProvider' import { SettingsHotkeyProvider } from './hotkeys' import { SettingsConfigProvider } from './config' -import { HotkeySettingsTabProvider, WindowSettingsTabProvider, VaultSettingsTabProvider, ProfilesSettingsTabProvider } from './settings' +import { HotkeySettingsTabProvider, WindowSettingsTabProvider, VaultSettingsTabProvider, ProfilesSettingsTabProvider, ConfigSyncSettingsTabProvider } from './settings' /** @hidden */ @NgModule({ @@ -41,6 +44,7 @@ import { HotkeySettingsTabProvider, WindowSettingsTabProvider, VaultSettingsTabP { provide: SettingsTabProvider, useClass: WindowSettingsTabProvider, multi: true }, { provide: SettingsTabProvider, useClass: VaultSettingsTabProvider, multi: true }, { provide: SettingsTabProvider, useClass: ProfilesSettingsTabProvider, multi: true }, + { provide: SettingsTabProvider, useClass: ConfigSyncSettingsTabProvider, multi: true }, ], entryComponents: [ EditProfileModalComponent, @@ -51,6 +55,7 @@ import { HotkeySettingsTabProvider, WindowSettingsTabProvider, VaultSettingsTabP SetVaultPassphraseModalComponent, VaultSettingsTabComponent, WindowSettingsTabComponent, + ConfigSyncSettingsTabComponent, ReleaseNotesComponent, ], declarations: [ @@ -64,10 +69,13 @@ import { HotkeySettingsTabProvider, WindowSettingsTabProvider, VaultSettingsTabP SetVaultPassphraseModalComponent, VaultSettingsTabComponent, WindowSettingsTabComponent, + ConfigSyncSettingsTabComponent, ReleaseNotesComponent, ], }) -export default class SettingsModule { } // eslint-disable-line @typescript-eslint/no-extraneous-class +export default class SettingsModule { + constructor (public configSync: ConfigSyncService) { } +} export * from './api' export { SettingsTabComponent } diff --git a/tabby-settings/src/services/configSync.service.ts b/tabby-settings/src/services/configSync.service.ts new file mode 100644 index 00000000..6c0b6bfe --- /dev/null +++ b/tabby-settings/src/services/configSync.service.ts @@ -0,0 +1,179 @@ +import * as yaml from 'js-yaml' +import axios from 'axios' +import { Injectable } from '@angular/core' +import { ConfigService, HostAppService, Logger, LogService, Platform, PlatformService } from 'tabby-core' + +export interface User { + id: number +} + +export interface Config { + id: number + name: string + content: string + last_used_with_version: string|null + created_at: Date + modified_at: Date +} + +const OPTIONAL_CONFIG_PARTS = ['hotkeys', 'appearance', 'vault'] + +@Injectable({ providedIn: 'root' }) +export class ConfigSyncService { + private logger: Logger + private lastRemoteChange = new Date(0) + + constructor ( + log: LogService, + private platform: PlatformService, + private hostApp: HostAppService, + private config: ConfigService, + ) { + this.logger = log.create('configSync') + config.ready$.toPromise().then(() => { + this.autoSync() + config.changed$.subscribe(() => { + if (this.isEnabled() && this.config.store.configSync.auto) { + this.upload() + } + }) + }) + } + + isAvailable (): boolean { + return this.config.store.enableExperimentalFeatures && this.hostApp.platform !== Platform.Web + } + + isEnabled (): boolean { + return this.isAvailable() && + !!this.config.store.configSync.host && + !!this.config.store.configSync.token && + !!this.config.store.configSync.configID + } + + async getConfigs (): Promise { + return this.request('GET', '/api/1/configs') + } + + async getConfig (id: number): Promise { + return this.request('GET', `/api/1/configs/${id}`) + } + + async updateConfig (id: number, data: Partial): Promise { + return this.request('PATCH', `/api/1/configs/${id}`, { data }) + } + + async getUser (): Promise { + return this.request('GET', '/api/1/user') + } + + async createNewConfig (name: string): Promise { + return this.request('POST', '/api/1/configs', { + data: { + name, + }, + }) + } + + setConfig (config: Config): void { + this.config.store.configSync.configID = config.id + this.config.save() + this.lastRemoteChange = new Date(config.modified_at) + } + + async upload (): Promise { + if (!this.isEnabled()) { + return + } + try { + const data = this.readConfigDataForSync() + const remoteData = yaml.load((await this.getConfig(this.config.store.configSync.configID)).content) as any + for (const part of OPTIONAL_CONFIG_PARTS) { + if (!this.config.store.configSync.parts[part]) { + data[part] = remoteData[part] + } + } + const content = yaml.dump(data) + const result = await this.updateConfig(this.config.store.configSync.configID, { + content, + last_used_with_version: this.platform.getAppVersion(), + }) + this.lastRemoteChange = new Date(result.modified_at) + this.logger.debug('Config uploaded') + } catch (error) { + this.logger.error('Upload failed:', error) + throw error + } + } + + async download (): Promise { + if (!this.isEnabled()) { + return + } + try { + const config = await this.getConfig(this.config.store.configSync.configID) + const data = yaml.load(config.content) as any + const localData = yaml.load(this.config.readRaw()) as any + data.configSync = localData.configSync + + for (const part of OPTIONAL_CONFIG_PARTS) { + if (!this.config.store.configSync.parts[part]) { + data[part] = localData[part] + } + } + + this.writeConfigDataFromSync(data) + this.logger.debug('Config downloaded') + } catch (error) { + this.logger.error('Download failed:', error) + throw error + } + } + + private readConfigDataForSync (): any { + const data = yaml.load(this.config.readRaw()) as any + delete data.configSync + return data + } + + private writeConfigDataFromSync (data: any) { + this.config.writeRaw(yaml.dump(data)) + } + + private async request (method: 'GET'|'POST'|'PATCH', url: string, params = {}) { + if (this.config.store.configSync.host.endsWith('/')) { + this.config.store.configSync.host = this.config.store.configSync.host.slice(0, -1) + } + url = this.config.store.configSync.host + url + this.logger.debug(`${method} ${url}`, params) + try { + const response = await axios.request({ + url, + method, + headers: { + Authorization: `Bearer ${this.config.store.configSync.token}`, + }, + ...params, + }) + this.logger.debug(response) + return response.data + } catch (error) { + this.logger.error(error) + throw error + } + } + + private async autoSync () { + while (true) { + if (this.isEnabled() && this.config.store.configSync.auto) { + const cfg = await this.getConfig(this.config.store.configSync.configID) + if (new Date(cfg.modified_at) > this.lastRemoteChange) { + this.logger.info('Remote config changed, downloading') + this.download() + this.lastRemoteChange = new Date(cfg.modified_at) + } + } + await new Promise(resolve => setTimeout(resolve, 5000)) + } + } +} diff --git a/tabby-settings/src/settings.ts b/tabby-settings/src/settings.ts index 4f704ee8..70b33051 100644 --- a/tabby-settings/src/settings.ts +++ b/tabby-settings/src/settings.ts @@ -3,7 +3,9 @@ import { SettingsTabProvider } from './api' import { HotkeySettingsTabComponent } from './components/hotkeySettingsTab.component' import { WindowSettingsTabComponent } from './components/windowSettingsTab.component' import { VaultSettingsTabComponent } from './components/vaultSettingsTab.component' +import { ConfigSyncSettingsTabComponent } from './components/configSyncSettingsTab.component' import { ProfilesSettingsTabComponent } from './components/profilesSettingsTab.component' +import { ConfigSyncService } from './services/configSync.service' /** @hidden */ @Injectable() @@ -55,3 +57,22 @@ export class ProfilesSettingsTabProvider extends SettingsTabProvider { return ProfilesSettingsTabComponent } } + +/** @hidden */ +@Injectable() +export class ConfigSyncSettingsTabProvider extends SettingsTabProvider { + id = 'config-sync' + icon = 'cloud' + title = 'Config sync' + + constructor ( + private configSync: ConfigSyncService, + ) { super() } + + getComponentType (): any { + if (!this.configSync.isAvailable()) { + return null + } + return ConfigSyncSettingsTabComponent + } +} diff --git a/tabby-terminal/src/settings.ts b/tabby-terminal/src/settings.ts index 72d4cdd7..8b6a2374 100644 --- a/tabby-terminal/src/settings.ts +++ b/tabby-terminal/src/settings.ts @@ -22,7 +22,7 @@ export class AppearanceSettingsTabProvider extends SettingsTabProvider { export class ColorSchemeSettingsTabProvider extends SettingsTabProvider { id = 'terminal-color-scheme' icon = 'palette' - title = 'Color Scheme' + title = 'Color scheme' getComponentType (): any { return ColorSchemeSettingsTabComponent