mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +00:00
[PM-22789] Handle Autotype Hotkey Event (#15840)
* [PM-22789] Handle full hotkey event for autotype * [PM-22789] Move userId into windows conditional check * [PM-22789] Refactor autotype service observables * [PM-22789] Address PR comments * [PM-22789] Refactor stringIsNotUndefinedNullAndEmpty() function
This commit is contained in:
@@ -91,6 +91,7 @@ export class InitService {
|
|||||||
containerService.attachToGlobal(this.win);
|
containerService.attachToGlobal(this.win);
|
||||||
|
|
||||||
await this.autofillService.init();
|
await this.autofillService.init();
|
||||||
|
await this.autotypeService.init();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||||
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
@@ -455,17 +455,15 @@ const safeProviders: SafeProvider[] = [
|
|||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: DesktopAutotypeService,
|
provide: DesktopAutotypeService,
|
||||||
useFactory: (
|
useClass: DesktopAutotypeService,
|
||||||
configService: ConfigService,
|
deps: [
|
||||||
globalStateProvider: GlobalStateProvider,
|
AccountService,
|
||||||
platformUtilsService: PlatformUtilsServiceAbstraction,
|
AuthService,
|
||||||
) =>
|
CipherServiceAbstraction,
|
||||||
new DesktopAutotypeService(
|
ConfigService,
|
||||||
configService,
|
GlobalStateProvider,
|
||||||
globalStateProvider,
|
PlatformUtilsServiceAbstraction,
|
||||||
platformUtilsService.getDevice() === DeviceType.WindowsDesktop,
|
],
|
||||||
),
|
|
||||||
deps: [ConfigService, GlobalStateProvider, PlatformUtilsServiceAbstraction],
|
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,70 @@
|
|||||||
import { autotype } from "@bitwarden/desktop-napi";
|
import { ipcMain, globalShortcut } from "electron";
|
||||||
|
|
||||||
import { DesktopAutotypeService } from "../services/desktop-autotype.service";
|
import { autotype } from "@bitwarden/desktop-napi";
|
||||||
|
import { LogService } from "@bitwarden/logging";
|
||||||
|
|
||||||
|
import { WindowMain } from "../../main/window.main";
|
||||||
|
import { stringIsNotUndefinedNullAndEmpty } from "../../utils";
|
||||||
|
|
||||||
export class MainDesktopAutotypeService {
|
export class MainDesktopAutotypeService {
|
||||||
constructor(private desktopAutotypeService: DesktopAutotypeService) {}
|
keySequence: string = "Alt+CommandOrControl+I";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private logService: LogService,
|
||||||
|
private windowMain: WindowMain,
|
||||||
|
) {}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.desktopAutotypeService.autotypeEnabled$.subscribe((enabled) => {
|
ipcMain.on("autofill.configureAutotype", (event, data) => {
|
||||||
if (enabled) {
|
if (data.enabled === true && !globalShortcut.isRegistered(this.keySequence)) {
|
||||||
this.enableAutotype();
|
this.enableAutotype();
|
||||||
} else {
|
} else if (data.enabled === false && globalShortcut.isRegistered(this.keySequence)) {
|
||||||
this.disableAutotype();
|
this.disableAutotype();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on("autofill.completeAutotypeRequest", (event, data) => {
|
||||||
|
const { response } = data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
stringIsNotUndefinedNullAndEmpty(response.username) &&
|
||||||
|
stringIsNotUndefinedNullAndEmpty(response.password)
|
||||||
|
) {
|
||||||
|
this.doAutotype(response.username, response.password);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: this will call into desktop native code
|
disableAutotype() {
|
||||||
private enableAutotype() {
|
if (globalShortcut.isRegistered(this.keySequence)) {
|
||||||
// eslint-disable-next-line no-console
|
globalShortcut.unregister(this.keySequence);
|
||||||
console.log("Enabling Autotype...");
|
|
||||||
|
|
||||||
const result = autotype.getForegroundWindowTitle();
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log("Window Title: " + result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: this will call into desktop native code
|
this.logService.info("Autotype disabled.");
|
||||||
private disableAutotype() {
|
}
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log("Disabling Autotype...");
|
private enableAutotype() {
|
||||||
|
const result = globalShortcut.register(this.keySequence, () => {
|
||||||
|
const windowTitle = autotype.getForegroundWindowTitle();
|
||||||
|
|
||||||
|
this.windowMain.win.webContents.send("autofill.listenAutotypeRequest", {
|
||||||
|
windowTitle,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
result
|
||||||
|
? this.logService.info("Autotype enabled.")
|
||||||
|
: this.logService.info("Enabling autotype failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private doAutotype(username: string, password: string) {
|
||||||
|
const inputPattern = username + "\t" + password;
|
||||||
|
const inputArray = new Array<number>(inputPattern.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < inputPattern.length; i++) {
|
||||||
|
inputArray[i] = inputPattern.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
autotype.typeInput(inputArray);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,4 +127,43 @@ export default {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
configureAutotype: (enabled: boolean) => {
|
||||||
|
ipcRenderer.send("autofill.configureAutotype", { enabled });
|
||||||
|
},
|
||||||
|
listenAutotypeRequest: (
|
||||||
|
fn: (
|
||||||
|
windowTitle: string,
|
||||||
|
completeCallback: (
|
||||||
|
error: Error | null,
|
||||||
|
response: { username?: string; password?: string },
|
||||||
|
) => void,
|
||||||
|
) => void,
|
||||||
|
) => {
|
||||||
|
ipcRenderer.on(
|
||||||
|
"autofill.listenAutotypeRequest",
|
||||||
|
(
|
||||||
|
event,
|
||||||
|
data: {
|
||||||
|
windowTitle: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { windowTitle } = data;
|
||||||
|
|
||||||
|
fn(windowTitle, (error, response) => {
|
||||||
|
if (error) {
|
||||||
|
ipcRenderer.send("autofill.completeError", {
|
||||||
|
windowTitle,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcRenderer.send("autofill.completeAutotypeRequest", {
|
||||||
|
windowTitle,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { combineLatest, map, Observable, of } from "rxjs";
|
import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
|
import { DeviceType } from "@bitwarden/common/enums";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import {
|
import {
|
||||||
GlobalStateProvider,
|
GlobalStateProvider,
|
||||||
AUTOTYPE_SETTINGS_DISK,
|
AUTOTYPE_SETTINGS_DISK,
|
||||||
KeyDefinition,
|
KeyDefinition,
|
||||||
} from "@bitwarden/common/platform/state";
|
} from "@bitwarden/common/platform/state";
|
||||||
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import { UserId } from "@bitwarden/user-core";
|
||||||
|
|
||||||
export const AUTOTYPE_ENABLED = new KeyDefinition<boolean>(
|
export const AUTOTYPE_ENABLED = new KeyDefinition<boolean>(
|
||||||
AUTOTYPE_SETTINGS_DISK,
|
AUTOTYPE_SETTINGS_DISK,
|
||||||
@@ -20,28 +28,83 @@ export class DesktopAutotypeService {
|
|||||||
autotypeEnabled$: Observable<boolean> = of(false);
|
autotypeEnabled$: Observable<boolean> = of(false);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private accountService: AccountService,
|
||||||
|
private authService: AuthService,
|
||||||
|
private cipherService: CipherService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private globalStateProvider: GlobalStateProvider,
|
private globalStateProvider: GlobalStateProvider,
|
||||||
private isWindows: boolean,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
) {
|
) {
|
||||||
if (this.isWindows) {
|
ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => {
|
||||||
|
const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle);
|
||||||
|
const firstCipher = possibleCiphers?.at(0);
|
||||||
|
|
||||||
|
return callback(null, {
|
||||||
|
username: firstCipher?.login?.username,
|
||||||
|
password: firstCipher?.login?.password,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) {
|
||||||
this.autotypeEnabled$ = combineLatest([
|
this.autotypeEnabled$ = combineLatest([
|
||||||
this.autotypeEnabledState.state$,
|
this.autotypeEnabledState.state$,
|
||||||
this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype),
|
this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype),
|
||||||
|
this.accountService.activeAccount$.pipe(
|
||||||
|
map((account) => account?.id),
|
||||||
|
switchMap((userId) => this.authService.authStatusFor$(userId)),
|
||||||
|
),
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(
|
map(
|
||||||
([autotypeEnabled, windowsDesktopAutotypeFeatureFlag]) =>
|
([autotypeEnabled, windowsDesktopAutotypeFeatureFlag, authStatus]) =>
|
||||||
autotypeEnabled && windowsDesktopAutotypeFeatureFlag,
|
autotypeEnabled &&
|
||||||
|
windowsDesktopAutotypeFeatureFlag &&
|
||||||
|
authStatus == AuthenticationStatus.Unlocked,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {}
|
this.autotypeEnabled$.subscribe((enabled) => {
|
||||||
|
ipc.autofill.configureAutotype(enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setAutotypeEnabledState(enabled: boolean): Promise<void> {
|
async setAutotypeEnabledState(enabled: boolean): Promise<void> {
|
||||||
await this.autotypeEnabledState.update(() => enabled, {
|
await this.autotypeEnabledState.update(() => enabled, {
|
||||||
shouldUpdate: (currentlyEnabled) => currentlyEnabled !== enabled,
|
shouldUpdate: (currentlyEnabled) => currentlyEnabled !== enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async matchCiphersToWindowTitle(windowTitle: string): Promise<CipherView[]> {
|
||||||
|
const URI_PREFIX = "APP:";
|
||||||
|
windowTitle = windowTitle.toLowerCase();
|
||||||
|
|
||||||
|
const ciphers = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(
|
||||||
|
map((account) => account?.id),
|
||||||
|
filter((userId): userId is UserId => userId != null),
|
||||||
|
switchMap((userId) => this.cipherService.cipherViews$(userId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const possibleCiphers = ciphers.filter((c) => {
|
||||||
|
return (
|
||||||
|
c.login?.username &&
|
||||||
|
c.login?.password &&
|
||||||
|
c.deletedDate == null &&
|
||||||
|
c.login?.uris.some((u) => {
|
||||||
|
if (u.uri?.indexOf(URI_PREFIX) !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = u.uri.substring(4).toLowerCase();
|
||||||
|
|
||||||
|
return windowTitle.indexOf(uri) > -1;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return possibleCiphers;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-
|
|||||||
import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service";
|
import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service";
|
||||||
import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service";
|
import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service";
|
||||||
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
|
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
|
||||||
import { DesktopAutotypeService } from "./autofill/services/desktop-autotype.service";
|
|
||||||
import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service";
|
import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service";
|
||||||
import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener";
|
import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener";
|
||||||
import { MainBiometricsService } from "./key-management/biometrics/main-biometrics.service";
|
import { MainBiometricsService } from "./key-management/biometrics/main-biometrics.service";
|
||||||
@@ -48,7 +47,6 @@ import { PowerMonitorMain } from "./main/power-monitor.main";
|
|||||||
import { TrayMain } from "./main/tray.main";
|
import { TrayMain } from "./main/tray.main";
|
||||||
import { UpdaterMain } from "./main/updater.main";
|
import { UpdaterMain } from "./main/updater.main";
|
||||||
import { WindowMain } from "./main/window.main";
|
import { WindowMain } from "./main/window.main";
|
||||||
import { SlimConfigService } from "./platform/config/slim-config.service";
|
|
||||||
import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main";
|
import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main";
|
||||||
import { ClipboardMain } from "./platform/main/clipboard.main";
|
import { ClipboardMain } from "./platform/main/clipboard.main";
|
||||||
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
|
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
|
||||||
@@ -307,13 +305,22 @@ export class Main {
|
|||||||
void this.nativeAutofillMain.init();
|
void this.nativeAutofillMain.init();
|
||||||
|
|
||||||
this.mainDesktopAutotypeService = new MainDesktopAutotypeService(
|
this.mainDesktopAutotypeService = new MainDesktopAutotypeService(
|
||||||
new DesktopAutotypeService(
|
this.logService,
|
||||||
new SlimConfigService(this.environmentService, globalStateProvider),
|
this.windowMain,
|
||||||
globalStateProvider,
|
|
||||||
process.platform === "win32",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app
|
||||||
|
.whenReady()
|
||||||
|
.then(() => {
|
||||||
this.mainDesktopAutotypeService.init();
|
this.mainDesktopAutotypeService.init();
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
this.logService.error("Error initializing Autotype.", reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
this.mainDesktopAutotypeService.disableAutotype();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap() {
|
bootstrap() {
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { combineLatest, map, Observable, throwError } from "rxjs";
|
|
||||||
import { SemVer } from "semver";
|
|
||||||
|
|
||||||
import {
|
|
||||||
FeatureFlag,
|
|
||||||
FeatureFlagValueType,
|
|
||||||
getFeatureFlagValue,
|
|
||||||
} from "@bitwarden/common/enums/feature-flag.enum";
|
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
|
||||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
|
||||||
import {
|
|
||||||
EnvironmentService,
|
|
||||||
Region,
|
|
||||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
|
||||||
import { ServerSettings } from "@bitwarden/common/platform/models/domain/server-settings";
|
|
||||||
import { GLOBAL_SERVER_CONFIGURATIONS } from "@bitwarden/common/platform/services/config/default-config.service";
|
|
||||||
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
|
||||||
import { UserId } from "@bitwarden/user-core";
|
|
||||||
|
|
||||||
/*
|
|
||||||
NOT FOR GENERAL USE
|
|
||||||
|
|
||||||
If you have more uses for the config service in the main process,
|
|
||||||
please reach out to platform.
|
|
||||||
*/
|
|
||||||
export class SlimConfigService implements ConfigService {
|
|
||||||
constructor(
|
|
||||||
private environmentService: EnvironmentService,
|
|
||||||
private globalStateProvider: GlobalStateProvider,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
serverConfig$: Observable<ServerConfig> = throwError(() => {
|
|
||||||
return new Error("Method not implemented.");
|
|
||||||
});
|
|
||||||
serverSettings$: Observable<ServerSettings> = throwError(() => {
|
|
||||||
return new Error("Method not implemented.");
|
|
||||||
});
|
|
||||||
cloudRegion$: Observable<Region> = throwError(() => {
|
|
||||||
return new Error("Method not implemented.");
|
|
||||||
});
|
|
||||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
|
|
||||||
return combineLatest([
|
|
||||||
this.environmentService.environment$,
|
|
||||||
this.globalStateProvider.get(GLOBAL_SERVER_CONFIGURATIONS).state$,
|
|
||||||
]).pipe(
|
|
||||||
map(([environment, serverConfigMap]) =>
|
|
||||||
getFeatureFlagValue(serverConfigMap?.[environment.getApiUrl()], key),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
userCachedFeatureFlag$<Flag extends FeatureFlag>(
|
|
||||||
key: Flag,
|
|
||||||
userId: UserId,
|
|
||||||
): Observable<FeatureFlagValueType<Flag>> {
|
|
||||||
throw new Error("Method not implemented.");
|
|
||||||
}
|
|
||||||
getFeatureFlag<Flag extends FeatureFlag>(key: Flag): Promise<FeatureFlagValueType<Flag>> {
|
|
||||||
throw new Error("Method not implemented.");
|
|
||||||
}
|
|
||||||
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer): Observable<boolean> {
|
|
||||||
throw new Error("Method not implemented.");
|
|
||||||
}
|
|
||||||
ensureConfigFetched(): Promise<void> {
|
|
||||||
throw new Error("Method not implemented.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -98,3 +98,11 @@ export function cleanUserAgent(userAgent: string): string {
|
|||||||
.replace(userAgentItem("Bitwarden", " "), "")
|
.replace(userAgentItem("Bitwarden", " "), "")
|
||||||
.replace(userAgentItem("Electron", " "), "");
|
.replace(userAgentItem("Electron", " "), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` if the provided string is not undefined, not null, and not empty.
|
||||||
|
* Otherwise, returns `false`.
|
||||||
|
*/
|
||||||
|
export function stringIsNotUndefinedNullAndEmpty(str: string): boolean {
|
||||||
|
return str?.length > 0;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user