/********************************************************************************
 * Copyright (C) 2017-2018 TypeFox and others.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the Eclipse
 * Public License v. 2.0 are satisfied: GNU General Public License, version 2
 * with the GNU Classpath Exception which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 ********************************************************************************/ import * as nsfw from '@theia/core/shared/nsfw' ; import { join } from 'path' ; import { promises as fsp } from 'fs' ; import { IMinimatch , Minimatch } from 'minimatch' ; import { FileUri } from '@theia/core/lib/node/file-uri' ; import { FileChangeType , FileSystemWatcherService , FileSystemWatcherServiceClient , WatchOptions } from '../../common/filesystem-watcher-protocol' ; import { FileChangeCollection } from '../file-change-collection' ; import { Deferred } from '@theia/core/lib/common/promise-util' ; export interface NsfwWatcherOptions { ignored : IMinimatch [ ] ; } export const NsfwFileSystemWatcherServerOptions = Symbol ( 'NsfwFileSystemWatcherServerOptions' ) ; export interface NsfwFileSystemWatcherServerOptions { verbose : boolean ; info : ( message : string , ... args : any [ ] ) => void ; error : ( message : string , ... args : any [ ] ) => void ; nsfwOptions : nsfw . Options ; } /**
 * This is a flag value passed around upon disposal.
 */ export const WatcherDisposal = Symbol ( 'WatcherDisposal' ) ; /**
 * Because URIs can be watched by different clients, we'll track
 * how many are listening for a given URI.
 *
 * This component wraps the whole start/stop process given some
 * reference count.
 *
 * Once there are no more references the handle
 * will wait for some time before destroying its resources.
 */ export class NsfwWatcher { protected static debugIdSequence = 0 ; protected disposed = false ; /**
     * Used for debugging to keep track of the watchers.
     */ protected debugId = NsfwWatcher . debugIdSequence ++ ; /**
     * When this field is set, it means the nsfw instance was successfully started.
     */ protected nsfw : nsfw . NSFW | undefined ; /**
     * When the ref count hits zero, we schedule this watch handle to be disposed.
     */ protected deferredDisposalTimer : NodeJS . Timer | undefined ; /**
     * This deferred only rejects with `WatcherDisposal` and never resolves.
     */ protected readonly deferredDisposalDeferred = new Deferred < never > ( ) ; /**
     * We count each reference made to this watcher, per client.
     *
     * We do this to know where to send events via the network.
     *
     * An entry should be removed when its value hits zero.
     */ protected readonly refsPerClient = new Map < number , { value : number ; } > ( ) ; /**
     * Ensures that events are processed in the order they are emitted,
     * despite being processed async.
     */ protected nsfwEventProcessingQueue : Promise < void > = Promise . resolve ( ) ; /**
     * Resolves once this handle disposed itself and its resources. Never throws.
     */ readonly whenDisposed : Promise < void > = this . deferredDisposalDeferred . promise . catch ( ( ) => undefined ) ; returns readonly whenStarted : Promise < boolean > ; constructor ( /** Initial reference to this handle. */ initialClientId , /** Filesystem path to be watched. */ readonly fsPath , /** Watcher-specific options */ readonly watcherOptions , /** Logging and Nsfw options */ protected readonly nsfwFileSystemWatchServerOptions , /** The client to forward events to. */ protected readonly fileSystemWatcherClient , /** Amount of time in ms to wait once this handle is not referenced anymore. */ protected readonly deferredDisposalTimeout = 10000 ) { this . refsPerClient . set ( initialClientId , { value : 1 } ) ; this . whenStarted = this . start ( ) . then ( ( ) => true , error => { if ( error === WatcherDisposal ) { return false ; } this . _dispose ( ) ; this . fireError ( ) ; throw error ; } ) ; this . debug ( 'NEW' , `initialClientId=${ initialClientId }` ) ; } addRef ( clientId ) { let refs = this . refsPerClient . get ( clientId ) ; if ( typeof refs === 'undefined' ) { this . refsPerClient . set ( clientId , refs = { value : 1 } ) ; } else { refs . value += 1 ; } const totalRefs = this . getTotalReferences ( ) ; const revived = totalRefs === 1 ; if ( revived ) { this . onRefsRevive ( ) ; } this . debug ( 'REF++' , `clientId=${ clientId }, clientRefs=${ refs . value }, totalRefs=${ totalRefs }. revived=${ revived }` ) ; } removeRef ( clientId ) { const refs = this . refsPerClient . get ( clientId ) ; if ( typeof refs === 'undefined' ) { this . info ( 'WARN REF--' , `removed one too many reference: clientId=${ clientId }` ) ; return ; } refs . value -= 1 ; if ( refs . value === 0 ) { this . refsPerClient . delete ( clientId ) ; } const totalRefs = this . getTotalReferences ( ) ; const dead = totalRefs === 0 ; if ( dead ) { this . onRefsReachZero ( ) ; } this . debug ( 'REF--' , `clientId=${ clientId }, clientRefs=${ refs . value }, totalRefs=${ totalRefs }, dead=${ dead }` ) ; } /**
     * All clients with at least one active reference.
     */ getClientIds ( ) { return Array . from ( this . refsPerClient . keys ( ) ) ; } /**
     * Add the references for each client together.
     */ getTotalReferences ( ) { let total = 0 ; for ( const refs of this . refsPerClient . values ( ) ) { total += refs . value ; } return total ; } /**
     * Returns true if at least one client listens to this handle.
     */ isInUse ( ) { return this . refsPerClient . size > 0 ; } /**
     * When starting a watcher, we'll first check and wait for the path to exists
     * before running an NSFW watcher.
     */ protected async start ( ) { while ( await this . orCancel ( fsp . stat ( this . fsPath ) . then ( ( ) => false , ( ) => true ) ) ) { await this . orCancel ( new Promise ( resolve => setTimeout ( resolve , 500 ) ) ) ; } const watcher = await this . orCancel ( this . createNsfw ( ) ) ; await this . orCancel ( watcher . start ( ) . then ( ( ) => { this . debug ( 'STARTED' , `disposed=${ this . disposed }` ) ; if ( this . disposed ) { this . stopNsfw ( watcher ) ; } } ) ) ; this . nsfw = watcher ; } /**
     * Given a started nsfw instance, gracefully shut it down.
     */ protected async stopNsfw ( watcher ) { await watcher . stop ( ) . then ( ( ) => 'success=true' , error => error ) . then ( status => this . debug ( 'STOPPED' , status ) ) ; } protected async createNsfw ( ) { const fsPath = await fsp . realpath ( this . fsPath ) ; return nsfw ( fsPath , events => this . handleNsfwEvents ( events ) , { ... this . nsfwFileSystemWatchServerOptions . nsfwOptions , errorCallback : error => { console . error ( `NSFW service error on "${ fsPath }":` , error ) ; this . _dispose ( ) ; this . fireError ( ) ; if ( this . nsfwFileSystemWatchServerOptions . nsfwOptions . errorCallback ) { this . nsfwFileSystemWatchServerOptions . nsfwOptions . errorCallback ( error ) ; } } , } ) ; } protected handleNsfwEvents ( events ) { if ( this . isInUse ( ) ) { this . nsfwEventProcessingQueue = this . nsfwEventProcessingQueue . then ( async ( ) => { const fileChangeCollection = new FileChangeCollection ( ) ; await Promise . all ( events . map ( async ( event ) => { if ( event . action === nsfw . actions . RENAMED ) { const [ oldPath , newPath ] = await Promise . all ( [ this . resolveEventPath ( event . directory , event . oldFile ! ) , this . resolveEventPath ( event . newDirectory || event . directory , event . newFile ! ) , ] ) ; this . pushFileChange ( fileChangeCollection , FileChangeType . DELETED , oldPath ) ; this . pushFileChange ( fileChangeCollection , FileChangeType . ADDED , newPath ) ; } else { const path = await this . resolveEventPath ( event . directory , event . file ! ) ; if ( event . action === nsfw . actions . CREATED ) { this . pushFileChange ( fileChangeCollection , FileChangeType . ADDED , path ) ; } else if ( event . action === nsfw . actions . DELETED ) { this . pushFileChange ( fileChangeCollection , FileChangeType . DELETED , path ) ; } else if ( event . action === nsfw . actions . MODIFIED ) { this . pushFileChange ( fileChangeCollection , FileChangeType . UPDATED , path ) ; } } } ) ) ; const changes = fileChangeCollection . values ( ) ; if ( changes . length > 0 ) { this . fileSystemWatcherClient . onDidFilesChanged ( { clients : this . getClientIds ( ) , changes , } ) ; } } , console . error ) ; } } protected async resolveEventPath ( directory , file ) { const path = join ( directory , file ) ; try { return await fsp . realpath ( path ) ; } catch { try { return join ( await fsp . realpath ( directory ) , file ) ; } catch { return path ; } } } protected pushFileChange ( changes , type , path ) { if ( ! this . isIgnored ( path ) ) { const uri = FileUri . create ( path ) . toString ( ) ; changes . push ( { type , uri } ) ; } } protected fireError ( ) { this . fileSystemWatcherClient . onError ( { clients : this . getClientIds ( ) , uri : this . fsPath , } ) ; } /**
     * Wrap a promise to reject as soon as this handle gets disposed.
     */ protected async orCancel < T > ( promise ) { return Promise . race < T > ( [ this . deferredDisposalDeferred . promise , promise ] ) ; } /**
     * When references hit zero, we'll schedule disposal for a bit later.
     *
     * This allows new references to reuse this watcher instead of creating a new one.
     *
     * e.g. A frontend disconnects for a few milliseconds before reconnecting again.
     */ protected onRefsReachZero ( ) { this . deferredDisposalTimer = setTimeout ( ( ) => this . _dispose ( ) , this . deferredDisposalTimeout ) ; } /**
     * If we get new references after hitting zero, let's unschedule our disposal and keep watching.
     */ protected onRefsRevive ( ) { if ( this . deferredDisposalTimer ) { clearTimeout ( this . deferredDisposalTimer ) ; this . deferredDisposalTimer = undefined ; } } protected isIgnored ( path ) { return this . watcherOptions . ignored . length > 0 && this . watcherOptions . ignored . some ( m => m . match ( path ) ) ; } /**
     * Internal disposal mechanism.
     */ protected async _dispose ( ) { if ( ! this . disposed ) { this . disposed = true ; this . deferredDisposalDeferred . reject ( WatcherDisposal ) ; if ( this . nsfw ) { this . stopNsfw ( this . nsfw ) ; this . nsfw = undefined ; } this . debug ( 'DISPOSED' ) ; } } protected info ( prefix , ... params ) { this . nsfwFileSystemWatchServerOptions . info ( `${ prefix } NsfwWatcher(${ this . debugId } at "${ this . fsPath }"):` , ... params ) ; } protected debug ( prefix , ... params ) { if ( this . nsfwFileSystemWatchServerOptions . verbose ) { this . info ( prefix , ... params ) ; } } } /**
 * Each time a client makes a watchRequest, we generate a unique watcherId for it.
 *
 * This watcherId will map to this handle type which keeps track of the clientId that made the request.
 */ export interface NsfwWatcherHandle { clientId : number ; watcher : NsfwWatcher ; } export class NsfwFileSystemWatcherService implements FileSystemWatcherService { protected client : FileSystemWatcherServiceClient | undefined ; protected watcherId = 0 ; protected readonly watchers = new Map < string , NsfwWatcher > ( ) ; protected readonly watcherHandles = new Map < number , NsfwWatcherHandle > ( ) ; protected readonly options : NsfwFileSystemWatcherServerOptions ; /**
     * `this.client` is undefined until someone sets it.
     */ protected readonly maybeClient : FileSystemWatcherServiceClient = { onDidFilesChanged : event => this . client ?. onDidFilesChanged ( event ) , onError : event => this . client ?. onError ( event ) , } ; constructor ( options ? ) { this . options = { nsfwOptions : { } , verbose : false , info : ( message , ... args ) => console . info ( message , ... args ) , error : ( message , ... args ) => console . error ( message , ... args ) , ... options } ; } setClient ( client ) { this . client = client ; } /**
     * A specific client requests us to watch a given `uri` according to some `options`.
     *
     * We internally re-use all the same `(uri, options)` pairs.
     */ async watchFileChanges ( clientId , uri , options ? ) { const resolvedOptions = this . resolveWatchOptions ( options ) ; const watcherKey = this . getWatcherKey ( uri , resolvedOptions ) ; let watcher = this . watchers . get ( watcherKey ) ; if ( watcher === undefined ) { const fsPath = FileUri . fsPath ( uri ) ; watcher = this . createWatcher ( clientId , fsPath , resolvedOptions ) ; watcher . whenDisposed . then ( ( ) => this . watchers . delete ( watcherKey ) ) ; this . watchers . set ( watcherKey , watcher ) ; } else { watcher . addRef ( clientId ) ; } const watcherId = this . watcherId ++ ; this . watcherHandles . set ( watcherId , { clientId , watcher } ) ; watcher . whenDisposed . then ( ( ) => this . watcherHandles . delete ( watcherId ) ) ; return watcherId ; } protected createWatcher ( clientId , fsPath , options ) { const watcherOptions = { ignored : options . ignored . map ( pattern => new Minimatch ( pattern , { dot : true } ) ) , } ; return new NsfwWatcher ( clientId , fsPath , watcherOptions , this . options , this . maybeClient ) ; } async unwatchFileChanges ( watcherId ) { const handle = this . watcherHandles . get ( watcherId ) ; if ( handle === undefined ) { console . warn ( `tried to de-allocate a disposed watcher: watcherId=${ watcherId }` ) ; } else { this . watcherHandles . delete ( watcherId ) ; handle . watcher . removeRef ( handle . clientId ) ; } } /**
     * Given some `URI` and some `WatchOptions`, generate a unique key.
     */ protected getWatcherKey ( uri , options ) { return [ uri , options . ignored . slice ( 0 ) . sort ( ) . join ( ) ] . join ( ) ; } /**
     * Return fully qualified options.
     */ protected resolveWatchOptions ( options ? ) { return { ignored : [ ] , ... options , } ; } protected debug ( message , ... params ) { if ( this . options . verbose ) { this . options . info ( message , ... params ) ; } } dispose ( ) { } }