1
1
mirror of https://github.com/Eugeny/tabby.git synced 2024-11-22 20:55:03 +03:00

experimental config sync

This commit is contained in:
Eugene Pankov 2021-07-24 16:31:32 +02:00
parent 99ab8dacd4
commit 69115fb77a
No known key found for this signature in database
GPG Key ID: 5896FCBBDD1CF4F4
13 changed files with 457 additions and 16 deletions

View File

@ -31,3 +31,4 @@ enableAutomaticUpdates: true
version: 1
vault: null
encrypted: false
enableExperimentalFeatures: false

View File

@ -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 => {

View File

@ -194,7 +194,6 @@ export class ConfigService {
}
async save (): Promise<void> {
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,
}
}
}

View File

@ -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 },
],

View File

@ -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({

View File

@ -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')

View File

@ -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')
}
}

View File

@ -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'
)

View File

@ -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: {

View File

@ -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 }

View File

@ -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<Config[]> {
return this.request('GET', '/api/1/configs')
}
async getConfig (id: number): Promise<Config> {
return this.request('GET', `/api/1/configs/${id}`)
}
async updateConfig (id: number, data: Partial<Config>): Promise<Config> {
return this.request('PATCH', `/api/1/configs/${id}`, { data })
}
async getUser (): Promise<any> {
return this.request('GET', '/api/1/user')
}
async createNewConfig (name: string): Promise<Config> {
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<void> {
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<void> {
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))
}
}
}

View File

@ -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
}
}

View File

@ -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