diff --git a/gulpfile.js b/gulpfile.js index 7fc0146e9b..c90d6361ef 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -30,6 +30,13 @@ const filters = { safari: [ '!build/safari/**/*' ], + webExt: [ + '!build/manifest.json' + ], + nonSafariApp: [ + '!build/background.html', + '!build/popup/index.html' + ], }; function buildString() { @@ -179,7 +186,6 @@ function safariCopyAssets(source, dest) { .on('error', reject) .pipe(gulpif('safari/Info.plist', replace('0.0.1', manifest.version))) .pipe(gulpif('safari/Info.plist', replace('0.0.2', process.env.BUILD_NUMBER || manifest.version))) - .pipe(gulpif('desktop.xcodeproj/project.pbxproj', replace('../../../build', '../safari/app'))) .pipe(gulp.dest(dest)) .on('end', resolve); }); @@ -189,7 +195,8 @@ function safariCopyBuild(source, dest) { return new Promise((resolve, reject) => { gulp.src(source) .on('error', reject) - .pipe(filter(['**'].concat(filters.fonts))) + .pipe(filter(['**'].concat(filters.fonts) + .concat(filters.webExt).concat(filters.nonSafariApp))) .pipe(gulp.dest(dest)) .on('end', resolve); }); diff --git a/src/background/commands.background.ts b/src/background/commands.background.ts index 463862852a..625eb4cd67 100644 --- a/src/background/commands.background.ts +++ b/src/background/commands.background.ts @@ -20,7 +20,7 @@ export default class CommandsBackground { } async init() { - if (this.isVivaldi) { + if (this.isSafari || this.isVivaldi) { BrowserApi.messageListener('commands.background', async (msg: any, sender: any, sendResponse: any) => { if (msg.command === 'keyboardShortcutTriggered' && msg.shortcut) { await this.processCommand(msg.shortcut, sender); diff --git a/src/background/main.background.ts b/src/background/main.background.ts index 82d1f739a6..a929d52749 100644 --- a/src/background/main.background.ts +++ b/src/background/main.background.ts @@ -167,8 +167,8 @@ export default class MainBackground { return promise.then((result) => result.response === 'unlocked'); } }); - this.storageService = new BrowserStorageService(); - this.secureStorageService = new BrowserStorageService(); + this.storageService = new BrowserStorageService(this.platformUtilsService); + this.secureStorageService = new BrowserStorageService(this.platformUtilsService); this.i18nService = new I18nService(BrowserApi.getUILanguage(window)); this.cryptoFunctionService = new WebCryptoFunctionService(window, this.platformUtilsService); this.consoleLogService = new ConsoleLogService(false); @@ -252,18 +252,21 @@ export default class MainBackground { this.commandsBackground = new CommandsBackground(this, this.passwordGenerationService, this.platformUtilsService, this.analytics, this.vaultTimeoutService); - this.tabsBackground = new TabsBackground(this); - this.contextMenusBackground = new ContextMenusBackground(this, this.cipherService, - this.passwordGenerationService, this.analytics, this.platformUtilsService, this.vaultTimeoutService, - this.eventService, this.totpService); - this.idleBackground = new IdleBackground(this.vaultTimeoutService, this.storageService, - this.notificationsService); - this.webRequestBackground = new WebRequestBackground(this.platformUtilsService, this.cipherService, - this.vaultTimeoutService); - this.windowsBackground = new WindowsBackground(this); + if (!this.isSafari) { + this.tabsBackground = new TabsBackground(this); + this.contextMenusBackground = new ContextMenusBackground(this, this.cipherService, + this.passwordGenerationService, this.analytics, this.platformUtilsService, this.vaultTimeoutService, + this.eventService, this.totpService); + this.idleBackground = new IdleBackground(this.vaultTimeoutService, this.storageService, + this.notificationsService); + this.webRequestBackground = new WebRequestBackground(this.platformUtilsService, this.cipherService, + this.vaultTimeoutService); + this.windowsBackground = new WindowsBackground(this); + } } async bootstrap() { + SafariApp.init(); this.analytics.ga('send', 'pageview', '/background.html'); this.containerService.attachToWindow(window); @@ -273,11 +276,13 @@ export default class MainBackground { await this.runtimeBackground.init(); await this.commandsBackground.init(); - await this.tabsBackground.init(); - await this.contextMenusBackground.init(); - await this.idleBackground.init(); - await this.webRequestBackground.init(); - await this.windowsBackground.init(); + if (!this.isSafari) { + await this.tabsBackground.init(); + await this.contextMenusBackground.init(); + await this.idleBackground.init(); + await this.webRequestBackground.init(); + await this.windowsBackground.init(); + } return new Promise((resolve) => { setTimeout(async () => { @@ -292,7 +297,7 @@ export default class MainBackground { } async setIcon() { - if (!chrome.browserAction && !this.sidebarAction) { + if (this.isSafari || (!chrome.browserAction && !this.sidebarAction)) { return; } @@ -311,7 +316,7 @@ export default class MainBackground { } async refreshBadgeAndMenu(forLocked: boolean = false) { - if (!chrome.windows || !chrome.contextMenus) { + if (this.isSafari || !chrome.windows || !chrome.contextMenus) { return; } @@ -442,7 +447,7 @@ export default class MainBackground { } private async buildContextMenu() { - if (!chrome.contextMenus || this.buildingContextMenu) { + if (this.isSafari || !chrome.contextMenus || this.buildingContextMenu) { return; } diff --git a/src/background/runtime.background.ts b/src/background/runtime.background.ts index 685ffc1414..f08915352b 100644 --- a/src/background/runtime.background.ts +++ b/src/background/runtime.background.ts @@ -4,6 +4,7 @@ import { CipherView } from 'jslib/models/view/cipherView'; import { LoginUriView } from 'jslib/models/view/loginUriView'; import { LoginView } from 'jslib/models/view/loginView'; +import { AuthService } from 'jslib/abstractions/auth.service'; import { AutofillService } from '../services/abstractions/autofill.service'; import BrowserPlatformUtilsService from '../services/browserPlatformUtils.service'; import { CipherService } from 'jslib/abstractions/cipher.service'; @@ -12,7 +13,10 @@ import { EnvironmentService } from 'jslib/abstractions/environment.service'; import { I18nService } from 'jslib/abstractions/i18n.service'; import { NotificationsService } from 'jslib/abstractions/notifications.service'; import { PolicyService } from 'jslib/abstractions/policy.service'; +import { PopupUtilsService } from '../popup/services/popup-utils.service'; +import { StateService } from 'jslib/abstractions/state.service'; import { StorageService } from 'jslib/abstractions/storage.service'; +import { SyncService } from 'jslib/abstractions/sync.service'; import { SystemService } from 'jslib/abstractions/system.service'; import { UserService } from 'jslib/abstractions/user.service'; import { VaultTimeoutService } from 'jslib/abstractions/vaultTimeout.service'; @@ -20,6 +24,7 @@ import { VaultTimeoutService } from 'jslib/abstractions/vaultTimeout.service'; import { BrowserApi } from '../browser/browserApi'; import MainBackground from './main.background'; +import { NativeMessagingBackground } from './nativeMessaging.background'; import { Analytics } from 'jslib/misc'; import { Utils } from 'jslib/misc/utils'; @@ -31,6 +36,7 @@ export default class RuntimeBackground { private runtime: any; private autofillTimeout: any; private pageDetailsToAutoFill: any[] = []; + private isSafari: boolean; private onInstalledReason: string = null; constructor(private main: MainBackground, private autofillService: AutofillService, @@ -40,15 +46,19 @@ export default class RuntimeBackground { private systemService: SystemService, private vaultTimeoutService: VaultTimeoutService, private environmentService: EnvironmentService, private policyService: PolicyService, private userService: UserService) { + this.isSafari = this.platformUtilsService.isSafari(); + this.runtime = this.isSafari ? {} : chrome.runtime; // onInstalled listener must be wired up before anything else, so we do it in the ctor - chrome.runtime.onInstalled.addListener((details: any) => { - this.onInstalledReason = details.reason; - }); + if (!this.isSafari) { + this.runtime.onInstalled.addListener((details: any) => { + this.onInstalledReason = details.reason; + }); + } } async init() { - if (!chrome.runtime) { + if (!this.runtime) { return; } @@ -389,6 +399,20 @@ export default class RuntimeBackground { } private async checkOnInstalled() { + if (this.isSafari) { + const installedVersion = await this.storageService.get(ConstantsService.installedVersionKey); + if (installedVersion == null) { + this.onInstalledReason = 'install'; + } else if (BrowserApi.getApplicationVersion() !== installedVersion) { + this.onInstalledReason = 'update'; + } + + if (this.onInstalledReason != null) { + await this.storageService.save(ConstantsService.installedVersionKey, + BrowserApi.getApplicationVersion()); + } + } + setTimeout(async () => { if (this.onInstalledReason != null) { if (this.onInstalledReason === 'install') { diff --git a/src/browser/browserApi.ts b/src/browser/browserApi.ts index 6bfd977b61..1ee54a45b1 100644 --- a/src/browser/browserApi.ts +++ b/src/browser/browserApi.ts @@ -4,16 +4,20 @@ import { Utils } from 'jslib/misc/utils'; export class BrowserApi { static isWebExtensionsApi: boolean = (typeof browser !== 'undefined'); - static isSafariApi: boolean = navigator.userAgent.indexOf(' Safari/') !== -1; + static isSafariApi: boolean = (window as any).safariAppExtension === true; static isChromeApi: boolean = !BrowserApi.isSafariApi && (typeof chrome !== 'undefined'); static isFirefoxOnAndroid: boolean = navigator.userAgent.indexOf('Firefox/') !== -1 && navigator.userAgent.indexOf('Android') !== -1; static async getTabFromCurrentWindowId(): Promise { - return await BrowserApi.tabsQueryFirst({ - active: true, - windowId: chrome.windows.WINDOW_ID_CURRENT, - }); + if (BrowserApi.isChromeApi) { + return await BrowserApi.tabsQueryFirst({ + active: true, + windowId: chrome.windows.WINDOW_ID_CURRENT, + }); + } else if (BrowserApi.isSafariApi) { + return await BrowserApi.getTabFromCurrentWindow(); + } } static async getTabFromCurrentWindow(): Promise { @@ -30,11 +34,16 @@ export class BrowserApi { } static async tabsQuery(options: any): Promise { - return new Promise((resolve) => { - chrome.tabs.query(options, (tabs: any[]) => { - resolve(tabs); + if (BrowserApi.isChromeApi) { + return new Promise((resolve) => { + chrome.tabs.query(options, (tabs: any[]) => { + resolve(tabs); + }); }); - }); + } else if (BrowserApi.isSafariApi) { + const tabs = await SafariApp.sendMessageToApp('tabs_query', JSON.stringify(options)); + return tabs != null ? JSON.parse(tabs) : null; + } } static async tabsQueryFirst(options: any): Promise { @@ -63,36 +72,81 @@ export class BrowserApi { return; } - return new Promise((resolve) => { - chrome.tabs.sendMessage(tab.id, obj, options, () => { - if (chrome.runtime.lastError) { - // Some error happened - } - resolve(); + if (BrowserApi.isChromeApi) { + return new Promise((resolve) => { + chrome.tabs.sendMessage(tab.id, obj, options, () => { + if (chrome.runtime.lastError) { + // Some error happened + } + resolve(); + }); }); - }); + } else if (BrowserApi.isSafariApi) { + if (options != null && options.frameId != null && obj.bitwardenFrameId == null) { + obj.bitwardenFrameId = options.frameId; + } + await SafariApp.sendMessageToApp('tabs_message', JSON.stringify({ + tab: tab, + obj: JSON.stringify(obj), + options: options, + }), true); + } } static getBackgroundPage(): any { - return chrome.extension.getBackgroundPage(); + if (BrowserApi.isChromeApi) { + return chrome.extension.getBackgroundPage(); + } else if (BrowserApi.isSafariApi) { + return window; + } else { + return null; + } } static getApplicationVersion(): string { - return chrome.runtime.getManifest().version; + if (BrowserApi.isChromeApi) { + return chrome.runtime.getManifest().version; + } else if (BrowserApi.isSafariApi) { + return (window as any).bitwardenApplicationVersion; + } else { + return null; + } } static async isPopupOpen(): Promise { - return Promise.resolve(chrome.extension.getViews({ type: 'popup' }).length > 0); + if (BrowserApi.isChromeApi) { + return Promise.resolve(chrome.extension.getViews({ type: 'popup' }).length > 0); + } else if (BrowserApi.isSafariApi) { + const open = await SafariApp.sendMessageToApp('isPopoverOpen'); + return open === 'true'; + } else { + return Promise.resolve(false); + } } static createNewTab(url: string, extensionPage: boolean = false) { - chrome.tabs.create({ url: url }); + if (BrowserApi.isChromeApi) { + chrome.tabs.create({ url: url }); + } else if (BrowserApi.isSafariApi) { + SafariApp.sendMessageToApp('createNewTab', url, true); + } } static messageListener(name: string, callback: (message: any, sender: any, response: any) => void) { - chrome.runtime.onMessage.addListener((msg: any, sender: any, response: any) => { - callback(msg, sender, response); - }); + if (BrowserApi.isChromeApi) { + chrome.runtime.onMessage.addListener((msg: any, sender: any, response: any) => { + callback(msg, sender, response); + }); + } else if (BrowserApi.isSafariApi) { + SafariApp.addMessageListener(name, (message: any, sender: any, response: any) => { + if (message.bitwardenFrameId != null) { + if (sender != null && typeof (sender) === 'object' && sender.frameId == null) { + sender.frameId = message.bitwardenFrameId; + } + } + callback(message, sender, response); + }); + } } static closePopup(win: Window) { @@ -101,8 +155,10 @@ export class BrowserApi { // condition is only called if the popup wasn't already dismissed (future proofing). // ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1433604 browser.tabs.update({ active: true }).finally(win.close); - } else { + } else if (BrowserApi.isWebExtensionsApi || BrowserApi.isChromeApi) { win.close(); + } else if (BrowserApi.isSafariApi) { + SafariApp.sendMessageToApp('hidePopover'); } } @@ -140,22 +196,30 @@ export class BrowserApi { } static getUILanguage(win: Window) { - return chrome.i18n.getUILanguage(); + if (BrowserApi.isSafariApi) { + return win.navigator.language; + } else { + return chrome.i18n.getUILanguage(); + } } static reloadExtension(win: Window) { if (win != null) { return win.location.reload(true); - } else { + } else if (BrowserApi.isSafariApi) { + SafariApp.sendMessageToApp('reloadExtension'); + } else if (!BrowserApi.isSafariApi) { return chrome.runtime.reload(); } } static reloadOpenWindows() { - const views = chrome.extension.getViews() as Window[]; - views.filter((w) => w.location.href != null).forEach((w) => { - w.location.reload(); - }); + if (!BrowserApi.isSafariApi) { + const views = chrome.extension.getViews() as Window[]; + views.filter((w) => w.location.href != null).forEach((w) => { + w.location.reload(); + }); + } } static connectNative(application: string): browser.runtime.Port | chrome.runtime.Port { diff --git a/src/browser/safariApp.ts b/src/browser/safariApp.ts index b53d3754b7..894ae2c6d4 100644 --- a/src/browser/safariApp.ts +++ b/src/browser/safariApp.ts @@ -1,6 +1,23 @@ import { BrowserApi } from './browserApi'; export class SafariApp { + static init() { + if ((window as any).bitwardenSafariAppInited) { + return; + } + (window as any).bitwardenSafariAppInited = true; + if (BrowserApi.isSafariApi) { + (window as any).bitwardenSafariAppRequests = + new Map void, timeoutDate: Date }>(); + (window as any).bitwardenSafariAppMessageListeners = + new Map void>(); + (window as any).bitwardenSafariAppMessageReceiver = (message: any) => { + SafariApp.receiveMessageFromApp(message); + }; + setInterval(() => SafariApp.cleanupOldRequests(), 5 * 60000); // every 5 mins + } + } + static sendMessageToApp(command: string, data: any = null, resolveNow = false): Promise { if (!BrowserApi.isSafariApi) { return Promise.resolve(null); @@ -8,14 +25,69 @@ export class SafariApp { return new Promise((resolve) => { const now = new Date(); const messageId = now.getTime().toString() + '_' + Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); - (browser as any).runtime.sendNativeMessage('com.bitwarden.desktop', { - id: messageId, - command: command, - data: data, - responseData: null, - }, (response: any) => { - resolve(response); + if (typeof safari === typeof undefined) { + (window as any).webkit.messageHandlers.bitwardenApp.postMessage(JSON.stringify({ + id: messageId, + command: command, + data: data, + responseData: null, + })); + } else { + safari.extension.dispatchMessage('bitwarden', { + command: command, + data: data, + responseData: null, + }); + } + if (resolveNow) { + resolve(); + } else { + (window as any).bitwardenSafariAppRequests.set(messageId, { + resolve: resolve, + timeoutDate: new Date(now.getTime() + 5 * 60000), + }); + } + }); + } + + static addMessageListener(name: string, callback: (message: any, sender: any, response: any) => void) { + (window as any).bitwardenSafariAppMessageListeners.set(name, callback); + } + + static sendMessageToListeners(message: any, sender: any, response: any) { + (window as any).bitwardenSafariAppMessageListeners.forEach((f: any) => f(message, sender, response)); + } + + private static receiveMessageFromApp(message: any) { + if (message == null) { + return; + } + if ((message.id == null || message.id === '') && message.command === 'app_message') { + try { + const msg = JSON.parse(message.data); + SafariApp.sendMessageToListeners(msg, { + id: 'app_message', + tab: message.senderTab, + }, null); + } catch { } + } else if (message.id != null && (window as any).bitwardenSafariAppRequests.has(message.id)) { + const p = (window as any).bitwardenSafariAppRequests.get(message.id); + p.resolve(message.responseData); + (window as any).bitwardenSafariAppRequests.delete(message.id); + } + } + + private static cleanupOldRequests() { + const removeIds: string[] = []; + ((window as any).bitwardenSafariAppRequests as + Map void, timeoutDate: Date }>) + .forEach((v, key) => { + if (v.timeoutDate < new Date()) { + removeIds.push(key); + } }); + removeIds.forEach((id) => { + (window as any).bitwardenSafariAppRequests.delete(id); }); } } diff --git a/src/content/autofill.js b/src/content/autofill.js index 956708fe3e..ca6c75ae08 100644 --- a/src/content/autofill.js +++ b/src/content/autofill.js @@ -989,6 +989,35 @@ End 1Password Extension */ + if ((typeof safari !== 'undefined') && navigator.userAgent.indexOf(' Safari/') !== -1 && + navigator.userAgent.indexOf('Chrome') === -1) { + if (window.__bitwardenFrameId == null) { + window.__bitwardenFrameId = Math.floor(Math.random() * Math.floor(99999999)); + } + safari.self.addEventListener('message', function (msgEvent) { + var msg = JSON.parse(msgEvent.message.msg); + if (msg.bitwardenFrameId != null && window.__bitwardenFrameId !== msg.bitwardenFrameId) { + return; + } + + if (msg.command === 'collectPageDetails') { + var pageDetails = collect(document); + var pageDetailsObj = JSON.parse(pageDetails); + safari.extension.dispatchMessage('bitwarden', { + command: 'collectPageDetailsResponse', + tab: msg.tab, + details: pageDetailsObj, + sender: msg.sender, + bitwardenFrameId: window.__bitwardenFrameId + }); + } + else if (msg.command === 'fillForm') { + fill(document, msg.fillScript); + } + }, false); + return; + } + chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) { if (msg.command === 'collectPageDetails') { var pageDetails = collect(document); diff --git a/src/content/autofiller.ts b/src/content/autofiller.ts index 1d756b8451..253c82603c 100644 --- a/src/content/autofiller.ts +++ b/src/content/autofiller.ts @@ -3,17 +3,44 @@ document.addEventListener('DOMContentLoaded', (event) => { let filledThisHref = false; let delayFillTimeout: number; - const enabledKey = 'enableAutoFillOnPageLoad'; - chrome.storage.local.get(enabledKey, (obj: any) => { - if (obj != null && obj[enabledKey] === true) { - setInterval(() => doFillIfNeeded(), 500); + const isSafari = (typeof safari !== 'undefined') && navigator.userAgent.indexOf(' Safari/') !== -1 && + navigator.userAgent.indexOf('Chrome') === -1; + + if (isSafari) { + if ((window as any).__bitwardenFrameId == null) { + (window as any).__bitwardenFrameId = Math.floor(Math.random() * Math.floor(99999999)); } - }); - chrome.runtime.onMessage.addListener((msg: any, sender: any, sendResponse: Function) => { - if (msg.command === 'fillForm' && pageHref === msg.url) { - filledThisHref = true; - } - }); + const responseCommand = 'autofillerAutofillOnPageLoadEnabledResponse'; + safari.extension.dispatchMessage('bitwarden', { + command: 'bgGetDataForTab', + responseCommand: responseCommand, + bitwardenFrameId: (window as any).__bitwardenFrameId, + }); + safari.self.addEventListener('message', (msgEvent: any) => { + const msg = JSON.parse(msgEvent.message.msg); + if (msg.bitwardenFrameId != null && (window as any).__bitwardenFrameId !== msg.bitwardenFrameId) { + return; + } + if (msg.command === responseCommand && msg.data.autofillEnabled === true) { + setInterval(() => doFillIfNeeded(), 500); + } else if (msg.command === 'fillForm' && pageHref === msg.url) { + filledThisHref = true; + } + }, false); + return; + } else { + const enabledKey = 'enableAutoFillOnPageLoad'; + chrome.storage.local.get(enabledKey, (obj: any) => { + if (obj != null && obj[enabledKey] === true) { + setInterval(() => doFillIfNeeded(), 500); + } + }); + chrome.runtime.onMessage.addListener((msg: any, sender: any, sendResponse: Function) => { + if (msg.command === 'fillForm' && pageHref === msg.url) { + filledThisHref = true; + } + }); + } function doFillIfNeeded(force: boolean = false) { if (force || pageHref !== window.location.href) { @@ -37,7 +64,12 @@ document.addEventListener('DOMContentLoaded', (event) => { sender: 'autofiller', }; - chrome.runtime.sendMessage(msg); + if (isSafari) { + msg.bitwardenFrameId = (window as any).__bitwardenFrameId; + safari.extension.dispatchMessage('bitwarden', msg); + } else { + chrome.runtime.sendMessage(msg); + } } } }); diff --git a/src/content/notificationBar.ts b/src/content/notificationBar.ts index 70fbd5e413..4974f53ad8 100644 --- a/src/content/notificationBar.ts +++ b/src/content/notificationBar.ts @@ -17,30 +17,71 @@ document.addEventListener('DOMContentLoaded', (event) => { const logInButtonNames = new Set(['log in', 'sign in', 'login', 'go', 'submit', 'continue', 'next']); const changePasswordButtonNames = new Set(['save password', 'update password', 'change password', 'change']); const changePasswordButtonContainsNames = new Set(['pass', 'change', 'contras', 'senha']); + let notificationBarData = null; + const isSafari = (typeof safari !== 'undefined') && navigator.userAgent.indexOf(' Safari/') !== -1 && + navigator.userAgent.indexOf('Chrome') === -1; let disabledAddLoginNotification = false; let disabledChangedPasswordNotification = false; - chrome.storage.local.get('neverDomains', (ndObj: any) => { - const domains = ndObj.neverDomains; - if (domains != null && domains.hasOwnProperty(window.location.hostname)) { + if (isSafari) { + if ((window as any).__bitwardenFrameId == null) { + (window as any).__bitwardenFrameId = Math.floor(Math.random() * Math.floor(99999999)); + } + if (inIframe) { return; } - chrome.storage.local.get('disableAddLoginNotification', (disAddObj: any) => { - disabledAddLoginNotification = disAddObj != null && disAddObj.disableAddLoginNotification === true; - chrome.storage.local.get('disableChangedPasswordNotification', (disChangedObj: any) => { - disabledChangedPasswordNotification = disChangedObj != null && - disChangedObj.disableChangedPasswordNotification === true; + const responseCommand = 'notificationBarDataResponse'; + safari.extension.dispatchMessage('bitwarden', { + command: 'bgGetDataForTab', + responseCommand: responseCommand, + bitwardenFrameId: (window as any).__bitwardenFrameId, + }); + safari.self.addEventListener('message', (msgEvent: any) => { + const msg = JSON.parse(msgEvent.message.msg); + if (msg.bitwardenFrameId != null && (window as any).__bitwardenFrameId !== msg.bitwardenFrameId) { + return; + } + if (msg.command === responseCommand && msg.data) { + notificationBarData = msg.data; + if (notificationBarData.neverDomains && + notificationBarData.neverDomains.hasOwnProperty(window.location.hostname)) { + return; + } + + disabledAddLoginNotification = notificationBarData.disabledAddLoginNotification === true; + disabledChangedPasswordNotification = notificationBarData.disabledChangedPasswordNotification === true; if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) { collectIfNeededWithTimeout(); } + } + + processMessages(msg, () => { /* do nothing on send response for Safari */ }); + }, false); + return; + } else { + chrome.storage.local.get('neverDomains', (ndObj: any) => { + const domains = ndObj.neverDomains; + if (domains != null && domains.hasOwnProperty(window.location.hostname)) { + return; + } + + chrome.storage.local.get('disableAddLoginNotification', (disAddObj: any) => { + disabledAddLoginNotification = disAddObj != null && disAddObj.disableAddLoginNotification === true; + chrome.storage.local.get('disableChangedPasswordNotification', (disChangedObj: any) => { + disabledChangedPasswordNotification = disChangedObj != null && + disChangedObj.disableChangedPasswordNotification === true; + if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) { + collectIfNeededWithTimeout(); + } + }); }); }); - }); - chrome.runtime.onMessage.addListener((msg: any, sender: any, sendResponse: Function) => { - processMessages(msg, sendResponse); - }); + chrome.runtime.onMessage.addListener((msg: any, sender: any, sendResponse: Function) => { + processMessages(msg, sendResponse); + }); + } function processMessages(msg: any, sendResponse: Function) { if (msg.command === 'openNotificationBar') { @@ -429,7 +470,7 @@ document.addEventListener('DOMContentLoaded', (event) => { } function closeExistingAndOpenBar(type: string, typeData: any) { - let barPage = 'notification/bar.html'; + let barPage = (isSafari ? 'app/' : '') + 'notification/bar.html'; switch (type) { case 'info': barPage = barPage + '?info=' + typeData.text; @@ -469,7 +510,7 @@ document.addEventListener('DOMContentLoaded', (event) => { return; } - const barPageUrl: string = chrome.extension.getURL(barPage); + const barPageUrl: string = isSafari ? (safari.extension.baseURI + barPage) : chrome.extension.getURL(barPage); const iframe = document.createElement('iframe'); iframe.style.cssText = 'height: 42px; width: 100%; border: 0; min-height: initial;'; @@ -539,6 +580,11 @@ document.addEventListener('DOMContentLoaded', (event) => { } function sendPlatformMessage(msg: any) { - chrome.runtime.sendMessage(msg); + if (isSafari) { + msg.bitwardenFrameId = (window as any).__bitwardenFrameId; + safari.extension.dispatchMessage('bitwarden', msg); + } else { + chrome.runtime.sendMessage(msg); + } } }); diff --git a/src/content/shortcuts.ts b/src/content/shortcuts.ts index fe9699ac52..f2d8de67dc 100644 --- a/src/content/shortcuts.ts +++ b/src/content/shortcuts.ts @@ -45,6 +45,11 @@ document.addEventListener('DOMContentLoaded', (event) => { shortcut: shortcut, }; - chrome.runtime.sendMessage(msg); + if (isSafari) { + msg.bitwardenFrameId = (window as any).__bitwardenFrameId; + safari.extension.dispatchMessage('bitwarden', msg); + } else { + chrome.runtime.sendMessage(msg); + } } }); diff --git a/src/content/sso.ts b/src/content/sso.ts index 508bc2aea3..a127a39d55 100644 --- a/src/content/sso.ts +++ b/src/content/sso.ts @@ -3,6 +3,15 @@ window.addEventListener('message', (event) => { return; if (event.data.command && (event.data.command === 'authResult')) { + if (typeof chrome === typeof undefined) { + safari.extension.dispatchMessage('bitwarden', { + command: event.data.command, + code: event.data.code, + state: event.data.state, + referrer: event.source.location.hostname, + }); + return; + } chrome.runtime.sendMessage({ command: event.data.command, code: event.data.code, diff --git a/src/notification/bar.js b/src/notification/bar.js index a5e55ca664..c882bd2931 100644 --- a/src/notification/bar.js +++ b/src/notification/bar.js @@ -3,21 +3,34 @@ require('./bar.scss'); document.addEventListener('DOMContentLoaded', () => { var i18n = {}; var lang = window.navigator.language; - - i18n.appName = chrome.i18n.getMessage('appName'); - i18n.close = chrome.i18n.getMessage('close'); - i18n.yes = chrome.i18n.getMessage('yes'); - i18n.never = chrome.i18n.getMessage('never'); - i18n.notificationAddSave = chrome.i18n.getMessage('notificationAddSave'); - i18n.notificationNeverSave = chrome.i18n.getMessage('notificationNeverSave'); - i18n.notificationAddDesc = chrome.i18n.getMessage('notificationAddDesc'); - i18n.notificationChangeSave = chrome.i18n.getMessage('notificationChangeSave'); - i18n.notificationChangeDesc = chrome.i18n.getMessage('notificationChangeDesc'); - lang = chrome.i18n.getUILanguage(); + if (typeof safari !== 'undefined') { + const responseCommand = 'notificationBarFrameDataResponse'; + sendPlatformMessage({ + command: 'bgGetDataForTab', + responseCommand: responseCommand + }); + safari.self.addEventListener('message', (msgEvent) => { + const msg = JSON.parse(msgEvent.message.msg); + if (msg.command === responseCommand && msg.data) { + i18n = msg.data.i18n; + load(); + } + }, false); + } else { + i18n.appName = chrome.i18n.getMessage('appName'); + i18n.close = chrome.i18n.getMessage('close'); + i18n.yes = chrome.i18n.getMessage('yes'); + i18n.never = chrome.i18n.getMessage('never'); + i18n.notificationAddSave = chrome.i18n.getMessage('notificationAddSave'); + i18n.notificationNeverSave = chrome.i18n.getMessage('notificationNeverSave'); + i18n.notificationAddDesc = chrome.i18n.getMessage('notificationAddDesc'); + i18n.notificationChangeSave = chrome.i18n.getMessage('notificationChangeSave'); + i18n.notificationChangeDesc = chrome.i18n.getMessage('notificationChangeDesc'); + lang = chrome.i18n.getUILanguage(); - // delay 50ms so that we get proper body dimensions - setTimeout(load, 50); - + // delay 50ms so that we get proper body dimensions + setTimeout(load, 50); + } function load() { var closeButton = document.getElementById('close-button'), @@ -118,6 +131,10 @@ document.addEventListener('DOMContentLoaded', () => { } function sendPlatformMessage(msg) { - chrome.runtime.sendMessage(msg); + if (typeof safari !== 'undefined') { + safari.extension.dispatchMessage('bitwarden', msg); + } else { + chrome.runtime.sendMessage(msg); + } } }); diff --git a/src/popup/accounts/two-factor.component.ts b/src/popup/accounts/two-factor.component.ts index 57aef6e4d9..a84ed5070a 100644 --- a/src/popup/accounts/two-factor.component.ts +++ b/src/popup/accounts/two-factor.component.ts @@ -58,12 +58,13 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { // ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1562620 this.initU2f = false; } + const isSafari = this.platformUtilsService.isSafari(); await super.ngOnInit(); if (this.selectedProviderType == null) { return; } - if (this.selectedProviderType === TwoFactorProviderType.Email && + if (!isSafari && this.selectedProviderType === TwoFactorProviderType.Email && this.popupUtilsService.inPopup(window)) { const confirmed = await this.platformUtilsService.showDialog(this.i18nService.t('popup2faCloseMessage'), null, this.i18nService.t('yes'), this.i18nService.t('no')); diff --git a/src/popup/app-routing.animations.ts b/src/popup/app-routing.animations.ts index 0c44460d62..b75b15e2a7 100644 --- a/src/popup/app-routing.animations.ts +++ b/src/popup/app-routing.animations.ts @@ -185,6 +185,8 @@ export const routerTransition = trigger('routerTransition', [ transition('tabs => premium', inSlideLeft), transition('premium => tabs', outSlideRight), - - transition('tabs => lock', inSlideDown), ]); + +if (!BrowserApi.isSafariApi) { + routerTransition.definitions.push(transition('tabs => lock', inSlideDown)); +} diff --git a/src/popup/components/pop-out.component.html b/src/popup/components/pop-out.component.html index 1c34a1c762..2f14e7c0cb 100644 --- a/src/popup/components/pop-out.component.html +++ b/src/popup/components/pop-out.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/popup/components/pop-out.component.ts b/src/popup/components/pop-out.component.ts index df9ceba57f..40e02c6906 100644 --- a/src/popup/components/pop-out.component.ts +++ b/src/popup/components/pop-out.component.ts @@ -22,7 +22,8 @@ export class PopOutComponent implements OnInit { ngOnInit() { if (this.show) { - if (this.popupUtilsService.inSidebar(window) && this.platformUtilsService.isFirefox()) { + this.show = !this.platformUtilsService.isSafari(); + if (this.show && this.popupUtilsService.inSidebar(window) && this.platformUtilsService.isFirefox()) { this.show = false; } } diff --git a/src/popup/services/popup-utils.service.ts b/src/popup/services/popup-utils.service.ts index 297c6c1470..8f694c87b4 100644 --- a/src/popup/services/popup-utils.service.ts +++ b/src/popup/services/popup-utils.service.ts @@ -68,6 +68,8 @@ export class PopupUtilsService { chrome.tabs.create({ url: href, }); + } else if ((typeof safari !== 'undefined')) { + // Safari can't open popup in full page tab :( } } } diff --git a/src/popup/settings/options.component.html b/src/popup/settings/options.component.html index 938bcd756e..6104b85d84 100644 --- a/src/popup/settings/options.component.html +++ b/src/popup/settings/options.component.html @@ -96,7 +96,7 @@ -
+
diff --git a/src/popup/settings/options.component.ts b/src/popup/settings/options.component.ts index e5cf6322bb..4050060617 100644 --- a/src/popup/settings/options.component.ts +++ b/src/popup/settings/options.component.ts @@ -29,6 +29,7 @@ export class OptionsComponent implements OnInit { disableChangedPasswordNotification = false; dontShowCards = false; dontShowIdentities = false; + showDisableContextMenu = true; showClearClipboard = true; theme: string; themeOptions: any[]; @@ -67,6 +68,8 @@ export class OptionsComponent implements OnInit { } async ngOnInit() { + this.showDisableContextMenu = !this.platformUtilsService.isSafari(); + this.enableAutoFillOnPageLoad = await this.storageService.get( ConstantsService.enableAutoFillOnPageLoadKey); diff --git a/src/popup/vault/current-tab.component.html b/src/popup/vault/current-tab.component.html index 0d4181f5cb..4fe67ae9b2 100644 --- a/src/popup/vault/current-tab.component.html +++ b/src/popup/vault/current-tab.component.html @@ -1,5 +1,5 @@
-
+
- - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/safari/desktop/Info.plist b/src/safari/desktop/Info.plist index 201669d037..3c9b847a73 100644 --- a/src/safari/desktop/Info.plist +++ b/src/safari/desktop/Info.plist @@ -11,13 +11,13 @@ CFBundleIconFile CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) + com.bitwarden.desktop CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + Bitwarden CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) + APPL CFBundleShortVersionString 1.0 CFBundleVersion diff --git a/src/safari/desktop/ViewController.swift b/src/safari/desktop/ViewController.swift index fbda5decf8..5af73ffee0 100644 --- a/src/safari/desktop/ViewController.swift +++ b/src/safari/desktop/ViewController.swift @@ -1,44 +1,14 @@ import Cocoa -import SafariServices.SFSafariApplication -import SafariServices.SFSafariExtensionManager - -let appName = "desktop" -let extensionBundleIdentifier = "com.bitwarden.desktop.Extension" class ViewController: NSViewController { - - @IBOutlet var appNameLabel: NSTextField! - override func viewDidLoad() { super.viewDidLoad() - self.appNameLabel.stringValue = appName - SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in - guard let state = state, error == nil else { - // Insert code to inform the user that something went wrong. - return - } - - DispatchQueue.main.async { - if (state.isEnabled) { - self.appNameLabel.stringValue = "\(appName)'s extension is currently on." - } else { - self.appNameLabel.stringValue = "\(appName)'s extension is currently off. You can turn it on in Safari Extensions preferences." - } - } - } - } - - @IBAction func openSafariExtensionPreferences(_ sender: AnyObject?) { - SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in - guard error == nil else { - // Insert code to inform the user that something went wrong. - return - } - - DispatchQueue.main.async { - NSApplication.shared.terminate(nil) - } - } + // Do any additional setup after loading the view. } + override var representedObject: Any? { + didSet { + // Update the view, if already loaded. + } + } } diff --git a/src/safari/safari/Base.lproj/SafariExtensionViewController.xib b/src/safari/safari/Base.lproj/SafariExtensionViewController.xib new file mode 100644 index 0000000000..3716df7567 --- /dev/null +++ b/src/safari/safari/Base.lproj/SafariExtensionViewController.xib @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/safari/safari/Info.plist b/src/safari/safari/Info.plist index 8f23fb23e0..82f785e9c6 100644 --- a/src/safari/safari/Info.plist +++ b/src/safari/safari/Info.plist @@ -9,13 +9,13 @@ CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) + com.bitwarden.desktop.safari CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + Bitwarden CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) + XPC! CFBundleShortVersionString 0.0.1 CFBundleVersion @@ -25,17 +25,63 @@ NSExtension NSExtensionPointIdentifier - com.apple.Safari.web-extension + com.apple.Safari.extension NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler + $(PRODUCT_MODULE_NAME).SafariExtensionHandler + SFSafariStyleSheet + + + Style Sheet + app/content/autofill.css + + + SFSafariContentScript + + + Script + app/content/autofill.js + + + Script + app/content/autofiller.js + + + Script + app/content/notificationBar.js + + + Script + app/content/shortcuts.js + + + Script + app/content/sso.js + + + SFSafariToolbarItem + + Action + Popover + Identifier + Button + Image + ToolbarItemIcon.pdf + Label + Bitwarden + + SFSafariWebsiteAccess + + Level + All + + SFSafariExtensionBundleIdentifiersToUninstall + + com.bitwarden.safari + NSHumanReadableCopyright Copyright © 2020 Bitwarden Inc. All rights reserved. NSHumanReadableDescription A secure and free password manager for all of your devices. - SFSafariAppExtensionBundleIdentifiersToReplace - - com.bitwarden.desktop.safari - diff --git a/src/safari/safari/SafariExtensionHandler.swift b/src/safari/safari/SafariExtensionHandler.swift new file mode 100644 index 0000000000..3d8711f401 --- /dev/null +++ b/src/safari/safari/SafariExtensionHandler.swift @@ -0,0 +1,101 @@ +import SafariServices + +class SafariExtensionHandler: SFSafariExtensionHandler { + override init() { + super.init() + SafariExtensionViewController.shared.initWebView() + } + + override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String: Any]?) { + // This method will be called when a content script provided by your extension + // calls safari.extension.dispatchMessage("message"). + if messageName == "bitwarden" { + page.getPropertiesWithCompletionHandler { properties in + DispatchQueue.main.async { + makeSenderTabObject(page: page, props: properties, complete: { senderTab in + DispatchQueue.main.async { + self.sendMessage(msg: userInfo, sender: senderTab) + } + }) + } + } + } + } + + override func toolbarItemClicked(in _: SFSafariWindow) { + // This method will be called when your toolbar item is clicked. + } + + override func validateToolbarItem(in _: SFSafariWindow, validationHandler: @escaping ((Bool, String) -> Void)) { + // This is called when Safari's state changed in some way that would require the extension's + // toolbar item to be validated again. + validationHandler(true, "") + } + + override func popoverViewController() -> SFSafariExtensionViewController { + return SafariExtensionViewController.shared + } + + override func popoverWillShow(in _: SFSafariWindow) { + SafariExtensionViewController.shared.popoverOpenCount += 1 + DispatchQueue.main.async { + self.sendMessage(msg: ["command": "reloadPopup"], sender: nil) + } + } + + override func popoverDidClose(in _: SFSafariWindow) { + SafariExtensionViewController.shared.popoverOpenCount -= 1 + } + + func sendMessage(msg: [String: Any]?, sender: Tab? = nil) { + if SafariExtensionViewController.shared.webView == nil { + return + } + let newMsg = AppMessage() + newMsg.command = "app_message" + newMsg.senderTab = sender + do { + let jsonData = try JSONSerialization.data(withJSONObject: msg as Any, options: []) + newMsg.data = String(data: jsonData, encoding: .utf8) + } catch let error { + print("error converting to json: \(error)") + } + SafariExtensionViewController.shared.replyMessage(message: newMsg) + } +} + +func makeSenderTabObject(page: SFSafariPage, props: SFSafariPageProperties?, complete: @escaping (Tab) -> Void) { + let t = Tab() + t.title = props?.title + t.url = props?.url?.absoluteString + page.getContainingTab { tab in + tab.getContainingWindow(completionHandler: { win in + guard let window = win else { + t.active = false; + t.windowId = -100 + SFSafariApplication.getAllWindows(completionHandler: { allWins in + if (allWins.count == 0) { + return + } + allWins[0].getAllTabs { allWinTabs in + t.index = allWinTabs.firstIndex(of: tab) ?? -1 + t.id = "\(t.windowId)_\(t.index)" + complete(t) + } + }) + return + } + window.getActiveTab(completionHandler: { activeTab in + t.active = activeTab != nil && tab == activeTab + SFSafariApplication.getAllWindows(completionHandler: { allWins in + t.windowId = allWins.firstIndex(of: window) ?? -100 + window.getAllTabs { allWinTabs in + t.index = allWinTabs.firstIndex(of: tab) ?? -1 + t.id = "\(t.windowId)_\(t.index)" + complete(t) + } + }) + }) + }) + } +} diff --git a/src/safari/safari/SafariExtensionViewController.swift b/src/safari/safari/SafariExtensionViewController.swift new file mode 100644 index 0000000000..0241df00ce --- /dev/null +++ b/src/safari/safari/SafariExtensionViewController.swift @@ -0,0 +1,440 @@ +import SafariServices +import WebKit + +class SafariExtensionViewController: SFSafariExtensionViewController, WKScriptMessageHandler, WKNavigationDelegate { + var webView: WKWebView! + var initedWebView: Bool = false + var popoverOpenCount: Int = 0 + + static let shared: SafariExtensionViewController = { + let shared = SafariExtensionViewController() + shared.preferredContentSize = NSSize(width: 375, height: 600) + return shared + }() + + func initWebView() { + if initedWebView { + return + } + initedWebView = true + let parentHeight = SafariExtensionViewController.shared.preferredContentSize.height + let parentWidth = SafariExtensionViewController.shared.preferredContentSize.width + let webViewConfig = WKWebViewConfiguration() + webViewConfig.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + webViewConfig.preferences.setValue(true, forKey: "developerExtrasEnabled") + webViewConfig.userContentController.add(self, name: "bitwardenApp") + webView = WKWebView(frame: CGRect(x: 0, y: 0, width: parentWidth, height: parentHeight), + configuration: webViewConfig) + webView.navigationDelegate = self + webView.allowsLinkPreview = false + navigateWebView("app/popup/index.html") + webView.alphaValue = 0.0 + webView.uiDelegate = self + view.addSubview(webView) + } + + func navigateWebView(_ relativeUrl: String){ + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let bundleUrl = Bundle.main.resourceURL!.absoluteURL + + if var urlComponents = URLComponents(string: bundleUrl.absoluteString + relativeUrl) { + if (urlComponents.queryItems?.first(where: { $0.name == "appVersion" })?.value == nil) { + urlComponents.queryItems = urlComponents.queryItems ?? [] + urlComponents.queryItems!.append(URLQueryItem(name: "appVersion", value: version)) + } + + webView.loadFileURL(urlComponents.url!, allowingReadAccessTo: bundleUrl) + } + } + + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + if #available(OSXApplicationExtension 10.12, *) { + NSAnimationContext.runAnimationGroup({ _ in + NSAnimationContext.current.duration = 0.35 + webView.animator().alphaValue = 1.0 + }) + } else { + // Fallback on earlier versions + } + } + + override func viewDidLoad() { + super.viewDidLoad() + let backgroundColor = NSColor(red: (39 / 255.0), green: (42 / 255.0), blue: (46 / 255.0), alpha: 1.0) + view.setValue(backgroundColor, forKey: "backgroundColor") + initWebView() + } + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name != "bitwardenApp" { + return + } + guard let messageBody = message.body as? String else { + return + } + guard let m: AppMessage = jsonDeserialize(json: messageBody) else { + return + } + let command = m.command + NSLog("Command: \(command)") + if command == "storage_get" { + if let data = m.data { + let obj = UserDefaults.standard.string(forKey: data) + m.responseData = obj + replyMessage(message: m) + } + } else if command == "storage_save" { + guard let data: StorageData = jsonDeserialize(json: m.data) else { + return + } + if let obj = data.obj { + UserDefaults.standard.set(obj, forKey: data.key) + } else { + UserDefaults.standard.removeObject(forKey: data.key) + } + replyMessage(message: m) + } else if command == "storage_remove" { + if let data = m.data { + UserDefaults.standard.removeObject(forKey: data) + replyMessage(message: m) + } + } else if command == "getLocaleStrings" { + let language = m.data ?? "en" + guard let bundleUrl = Bundle.main.resourceURL?.absoluteURL else { + return + } + let messagesUrl = bundleUrl.appendingPathComponent("app/_locales/\(language)/messages.json") + do { + let json = try String(contentsOf: messagesUrl, encoding: .utf8) + webView.evaluateJavaScript("window.bitwardenLocaleStrings = \(json);", completionHandler: {(result, error) in + guard let err = error else { + return; + } + NSLog("evaluateJavaScript error : %@", err.localizedDescription); + }) + } catch { + NSLog("ERROR on getLocaleStrings, \(error)") + } + replyMessage(message: m) + } else if command == "tabs_query" { + guard let options: TabQueryOptions = jsonDeserialize(json: m.data) else { + return + } + if options.currentWindow ?? false { + SFSafariApplication.getActiveWindow { win in + if win != nil { + processWindowsForTabs(wins: [win!], options: options, complete: { tabs in + m.responseData = jsonSerialize(obj: tabs) + self.replyMessage(message: m) + }) + } else { + SFSafariApplication.getAllWindows { wins in + processWindowsForTabs(wins: wins, options: options, complete: { tabs in + m.responseData = jsonSerialize(obj: tabs) + self.replyMessage(message: m) + }) + } + } + } + } else { + SFSafariApplication.getAllWindows { wins in + processWindowsForTabs(wins: wins, options: options, complete: { tabs in + m.responseData = jsonSerialize(obj: tabs) + self.replyMessage(message: m) + }) + } + } + } else if command == "tabs_message" { + guard let tabMsg: TabMessage = jsonDeserialize(json: m.data) else { + return + } + SFSafariApplication.getAllWindows { wins in + var theWin: SFSafariWindow? + var winIndex = 0 + for win in wins { + if tabMsg.tab.windowId == winIndex { + theWin = win + break + } + winIndex = winIndex + 1 + } + var theTab: SFSafariTab? + theWin?.getAllTabs { tabs in + var tabIndex = 0 + for tab in tabs { + if tabMsg.tab.index == tabIndex { + theTab = tab + break + } + tabIndex = tabIndex + 1 + } + theTab?.getActivePage { activePage in + activePage?.dispatchMessageToScript(withName: "bitwarden", userInfo: ["msg": tabMsg.obj]) + } + } + } + } else if command == "hidePopover" { + dismissPopover() + replyMessage(message: m) + } else if command == "showPopover" { + if popoverOpenCount <= 0 { + SFSafariApplication.getActiveWindow { win in + win?.getToolbarItem(completionHandler: { item in + item?.showPopover() + }) + } + } + } else if command == "isPopoverOpen" { + m.responseData = popoverOpenCount > 0 ? "true" : "false" + replyMessage(message: m) + } else if command == "createNewTab" { + if let data = m.data, let url = URL(string: data) { + if !data.starts(with: "https://") && !data.starts(with: "http://") { + SFSafariApplication.getActiveWindow { win in + win?.getToolbarItem(completionHandler: { item in + item?.showPopover() + self.navigateWebView("app/" + url.absoluteString) + }) + } + } + SFSafariApplication.getActiveWindow { win in + win?.openTab(with: url, makeActiveIfPossible: true, completionHandler: { _ in + // Tab opened + }) + } + } + } else if command == "reloadExtension" { + webView?.reload() + replyMessage(message: m) + } else if command == "copyToClipboard" { + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil) + pasteboard.setString(m.data ?? "", forType: NSPasteboard.PasteboardType.string) + replyMessage(message: m) + } else if command == "readFromClipboard" { + let pasteboard = NSPasteboard.general + m.responseData = pasteboard.pasteboardItems?.first?.string(forType: .string) + replyMessage(message: m) + } else if command == "downloadFile" { + guard let jsonData = m.data else { + return + } + guard let dlMsg: DownloadFileMessage = jsonDeserialize(json: jsonData) else { + return + } + var blobData: Data? + if dlMsg.blobOptions?.type == "text/plain" { + blobData = dlMsg.blobData?.data(using: .utf8) + } else if let blob = dlMsg.blobData { + blobData = Data(base64Encoded: blob) + } + guard let data = blobData else { + return + } + let panel = NSSavePanel() + panel.canCreateDirectories = true + panel.nameFieldStringValue = dlMsg.fileName + panel.begin { response in + if response == NSApplication.ModalResponse.OK { + if let url = panel.url { + do { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: url.absoluteString) { + fileManager.createFile(atPath: url.absoluteString, contents: Data(), + attributes: nil) + } + try data.write(to: url) + } catch { + print(error) + NSLog("ERROR in downloadFile, \(error)") + } + } + } + } + } + } + + func replyMessage(message: AppMessage) { + if webView == nil { + return + } + let json = (jsonSerialize(obj: message) ?? "null") + webView.evaluateJavaScript("window.bitwardenSafariAppMessageReceiver(\(json));", completionHandler: {(result, error) in + guard let err = error else { + return; + } + NSLog("evaluateJavaScript error : %@", err.localizedDescription); + }) + } +} + +extension SafariExtensionViewController: WKUIDelegate { + @available(OSXApplicationExtension 10.12, *) + func webView(_: WKWebView, runOpenPanelWith _: WKOpenPanelParameters, initiatedByFrame _: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void) { + let openPanel = NSOpenPanel() + openPanel.canChooseFiles = true + openPanel.begin { result in + if result == NSApplication.ModalResponse.OK && openPanel.url != nil { + completionHandler([openPanel.url!]) + } else { + completionHandler(nil) + } + } + } +} + +func processWindowsForTabs(wins: [SFSafariWindow], options: TabQueryOptions?, complete: @escaping ([Tab]) -> Void) { + if wins.count == 0 { + complete([]) + return + } + var newTabs: [Tab] = [] + let winGroup = DispatchGroup() + for win in wins { + winGroup.enter() + win.getActiveTab { activeTab in + win.getAllTabs { allTabs in + let tabGroup = DispatchGroup() + for tab in allTabs { + tabGroup.enter() + if options?.active ?? false { + if activeTab != nil && activeTab == tab { + let windowIndex = wins.firstIndex(of: win) ?? -100 + let tabIndex = allTabs.firstIndex(of: tab) ?? -1 + makeTabObject(tab: tab, activeTab: activeTab, windowIndex: windowIndex, + tabIndex: tabIndex, complete: { t in + newTabs.append(t) + tabGroup.leave() + }) + } else { + tabGroup.leave() + } + } else { + let windowIndex = wins.firstIndex(of: win) ?? -100 + let tabIndex = allTabs.firstIndex(of: tab) ?? -1 + makeTabObject(tab: tab, activeTab: activeTab, windowIndex: windowIndex, + tabIndex: tabIndex, complete: { t in + newTabs.append(t) + tabGroup.leave() + }) + } + } + tabGroup.notify(queue: .main) { + winGroup.leave() + } + } + } + } + winGroup.notify(queue: .main) { + complete(newTabs) + } +} + +func makeTabObject(tab: SFSafariTab, activeTab: SFSafariTab?, windowIndex: Int, tabIndex: Int, + complete: @escaping (Tab) -> Void) { + let t = Tab() + t.active = activeTab != nil && tab == activeTab + t.windowId = windowIndex + t.index = tabIndex + t.id = "\(windowIndex)_\(tabIndex)" + tab.getActivePage { page in + guard let activePage = page else { + complete(t) + return + } + activePage.getPropertiesWithCompletionHandler({ props in + t.title = props?.title + t.url = props?.url?.absoluteString + complete(t) + }) + } +} + +func jsonSerialize(obj: T?) -> String? { + let encoder = JSONEncoder() + do { + let data = try encoder.encode(obj) + return String(data: data, encoding: .utf8) ?? "null" + } catch _ { + return "null" + } +} + +func jsonDeserialize(json: String?) -> T? { + if json == nil { + return nil + } + let decoder = JSONDecoder() + do { + let obj = try decoder.decode(T.self, from: json!.data(using: .utf8)!) + return obj + } catch _ { + return nil + } +} + +class AppMessage: Decodable, Encodable { + init() { + id = "" + command = "" + data = nil + responseData = nil + responseError = nil + } + + var id: String + var command: String + var data: String? + var responseData: String? + var responseError: Bool? + var senderTab: Tab? +} + +class StorageData: Decodable, Encodable { + var key: String + var obj: String? +} + +class TabQueryOptions: Decodable, Encodable { + var currentWindow: Bool? + var active: Bool? +} + +class Tab: Decodable, Encodable { + init() { + id = "" + index = -1 + windowId = -100 + title = "" + active = false + url = "" + } + + var id: String + var index: Int + var windowId: Int + var title: String? + var active: Bool + var url: String? +} + +class TabMessage: Decodable, Encodable { + var tab: Tab + var obj: String + var options: TabMessageOptions? +} + +class TabMessageOptions: Decodable, Encodable { + var frameId: Int? +} + +class DownloadFileMessage: Decodable, Encodable { + var fileName: String + var blobData: String? + var blobOptions: DownloadFileMessageBlobOptions? +} + +class DownloadFileMessageBlobOptions: Decodable, Encodable { + var type: String? +} diff --git a/src/safari/safari/SafariWebExtensionHandler.swift b/src/safari/safari/SafariWebExtensionHandler.swift deleted file mode 100644 index 001d388e6e..0000000000 --- a/src/safari/safari/SafariWebExtensionHandler.swift +++ /dev/null @@ -1,109 +0,0 @@ -import SafariServices -import os.log - -let SFExtensionMessageKey = "message" - -class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { - - func beginRequest(with context: NSExtensionContext) { - let item = context.inputItems[0] as! NSExtensionItem - let message = item.userInfo?[SFExtensionMessageKey] as AnyObject? - os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@", message as! CVarArg) - - let response = NSExtensionItem() - - guard let command = message?["command"] as? String else { - return - } - - switch (command) { - case "readFromClipboard": - let pasteboard = NSPasteboard.general - response.userInfo = [ SFExtensionMessageKey: pasteboard.pasteboardItems?.first?.string(forType: .string) as Any ] - break - case "showPopover": - SFSafariApplication.getActiveWindow { win in - win?.getToolbarItem(completionHandler: { item in - item?.showPopover() - }) - } - break - case "downloadFile": - guard let jsonData = message?["data"] as? String else { - return - } - guard let dlMsg: DownloadFileMessage = jsonDeserialize(json: jsonData) else { - return - } - var blobData: Data? - if dlMsg.blobOptions?.type == "text/plain" { - blobData = dlMsg.blobData?.data(using: .utf8) - } else if let blob = dlMsg.blobData { - blobData = Data(base64Encoded: blob) - } - guard let data = blobData else { - return - } - let panel = NSSavePanel() - panel.canCreateDirectories = true - panel.nameFieldStringValue = dlMsg.fileName - panel.begin { response in - if response == NSApplication.ModalResponse.OK { - if let url = panel.url { - do { - let fileManager = FileManager.default - if !fileManager.fileExists(atPath: url.absoluteString) { - fileManager.createFile(atPath: url.absoluteString, contents: Data(), - attributes: nil) - } - try data.write(to: url) - } catch { - print(error) - NSLog("ERROR in downloadFile, \(error)") - } - } - } - } - break - - default: - return - } - - context.completeRequest(returningItems: [response], completionHandler: nil) - } - -} - -func jsonSerialize(obj: T?) -> String? { - let encoder = JSONEncoder() - do { - let data = try encoder.encode(obj) - return String(data: data, encoding: .utf8) ?? "null" - } catch _ { - return "null" - } -} - -func jsonDeserialize(json: String?) -> T? { - if json == nil { - return nil - } - let decoder = JSONDecoder() - do { - let obj = try decoder.decode(T.self, from: json!.data(using: .utf8)!) - return obj - } catch _ { - return nil - } -} - -class DownloadFileMessage: Decodable, Encodable { - var fileName: String - var blobData: String? - var blobOptions: DownloadFileMessageBlobOptions? -} - -class DownloadFileMessageBlobOptions: Decodable, Encodable { - var type: String? -} diff --git a/src/safari/safari/ToolbarItemIcon.pdf b/src/safari/safari/ToolbarItemIcon.pdf new file mode 100644 index 0000000000..dbf98ce90e Binary files /dev/null and b/src/safari/safari/ToolbarItemIcon.pdf differ diff --git a/src/safari/safari/app/popup/index.html b/src/safari/safari/app/popup/index.html new file mode 100644 index 0000000000..a6282445c3 --- /dev/null +++ b/src/safari/safari/app/popup/index.html @@ -0,0 +1,29 @@ + + + + + + + Bitwarden + + + + + + +
+
+ + + + + + + + + diff --git a/src/services/browserMessaging.service.ts b/src/services/browserMessaging.service.ts index e5bd9b1e98..01f94269fb 100644 --- a/src/services/browserMessaging.service.ts +++ b/src/services/browserMessaging.service.ts @@ -1,8 +1,16 @@ +import { BrowserApi } from '../browser/browserApi'; +import { SafariApp } from '../browser/safariApp'; + import { MessagingService } from 'jslib/abstractions/messaging.service'; export default class BrowserMessagingService implements MessagingService { send(subscriber: string, arg: any = {}) { const message = Object.assign({}, { command: subscriber }, arg); - chrome.runtime.sendMessage(message); + if (BrowserApi.isSafariApi) { + SafariApp.sendMessageToApp(subscriber, arg); + SafariApp.sendMessageToListeners(message, 'BrowserMessagingService', null); + } else { + chrome.runtime.sendMessage(message); + } } } diff --git a/src/services/browserPlatformUtils.service.ts b/src/services/browserPlatformUtils.service.ts index 88fd5b2ee0..38bab53b43 100644 --- a/src/services/browserPlatformUtils.service.ts +++ b/src/services/browserPlatformUtils.service.ts @@ -27,7 +27,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService return this.deviceCache; } - if (navigator.userAgent.indexOf(' Safari/') !== -1) { + if (this.isSafariExtension()) { this.deviceCache = DeviceType.SafariExtension; } else if (navigator.userAgent.indexOf(' Firefox/') !== -1 || navigator.userAgent.indexOf(' Gecko/') !== -1) { this.deviceCache = DeviceType.FirefoxExtension; @@ -190,7 +190,13 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } const clearing = options ? !!options.clearing : false; const clearMs: number = options && options.clearMs ? options.clearMs : null; - if (this.isFirefox() && (win as any).navigator.clipboard && (win as any).navigator.clipboard.writeText) { + if (this.isSafariExtension()) { + SafariApp.sendMessageToApp('copyToClipboard', text).then(() => { + if (!clearing && this.clipboardWriteCallback != null) { + this.clipboardWriteCallback(text, clearMs); + } + }); + } else if (this.isFirefox() && (win as any).navigator.clipboard && (win as any).navigator.clipboard.writeText) { (win as any).navigator.clipboard.writeText(text).then(() => { if (!clearing && this.clipboardWriteCallback != null) { this.clipboardWriteCallback(text, clearMs); @@ -238,7 +244,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService doc = options.doc; } - if (this.isSafari()) { + if (this.isSafariExtension()) { return await SafariApp.sendMessageToApp('readFromClipboard'); } else if (this.isFirefox() && (win as any).navigator.clipboard && (win as any).navigator.clipboard.readText) { return await (win as any).navigator.clipboard.readText(); @@ -305,6 +311,10 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService return false; } + private isSafariExtension(): boolean { + return (window as any).safariAppExtension === true; + } + getDefaultSystemTheme() { return this.prefersColorSchemeDark.matches ? 'dark' : 'light'; } diff --git a/src/services/browserStorage.service.ts b/src/services/browserStorage.service.ts index 9d3a6bc677..7510327d7d 100644 --- a/src/services/browserStorage.service.ts +++ b/src/services/browserStorage.service.ts @@ -1,47 +1,63 @@ -import { StorageService } from 'jslib/abstractions/storage.service'; +import { + PlatformUtilsService, + StorageService, +} from 'jslib/abstractions'; + +import { SafariApp } from '../browser/safariApp'; export default class BrowserStorageService implements StorageService { private chromeStorageApi: any; + private isSafari: boolean; - constructor() { - this.chromeStorageApi = chrome.storage.local; + constructor(platformUtilsService: PlatformUtilsService) { + this.isSafari = platformUtilsService.isSafari(); + if (!this.isSafari) { + this.chromeStorageApi = chrome.storage.local; + } } async get(key: string): Promise { - return new Promise((resolve) => { - this.chromeStorageApi.get(key, (obj: any) => { - if (obj != null && obj[key] != null) { - resolve(obj[key] as T); - return; - } - resolve(null); + if (this.isSafari) { + const obj = await SafariApp.sendMessageToApp('storage_get', key); + return obj == null ? null : JSON.parse(obj) as T; + } else { + return new Promise((resolve) => { + this.chromeStorageApi.get(key, (obj: any) => { + if (obj != null && obj[key] != null) { + resolve(obj[key] as T); + return; + } + resolve(null); + }); }); - }); + } } async save(key: string, obj: any): Promise { - if (obj == null) { - // Fix safari not liking null in set + const keyedObj = { [key]: obj }; + if (this.isSafari) { + await SafariApp.sendMessageToApp('storage_save', JSON.stringify({ + key: key, + obj: JSON.stringify(obj), + })); + } else { + return new Promise((resolve) => { + this.chromeStorageApi.set(keyedObj, () => { + resolve(); + }); + }); + } + } + + async remove(key: string): Promise { + if (this.isSafari) { + await SafariApp.sendMessageToApp('storage_remove', key); + } else { return new Promise((resolve) => { this.chromeStorageApi.remove(key, () => { resolve(); }); }); } - - const keyedObj = { [key]: obj }; - return new Promise((resolve) => { - this.chromeStorageApi.set(keyedObj, () => { - resolve(); - }); - }); - } - - async remove(key: string): Promise { - return new Promise((resolve) => { - this.chromeStorageApi.remove(key, () => { - resolve(); - }); - }); } } diff --git a/src/services/i18n.service.ts b/src/services/i18n.service.ts index 542dbb6f35..4eb880424d 100644 --- a/src/services/i18n.service.ts +++ b/src/services/i18n.service.ts @@ -1,11 +1,19 @@ import { I18nService as BaseI18nService } from 'jslib/services/i18n.service'; +import { BrowserApi } from '../browser/browserApi'; +import { SafariApp } from '../browser/safariApp'; + export default class I18nService extends BaseI18nService { constructor(systemLanguage: string) { - super(systemLanguage, null, async (formattedLocale: string) => { - // Deprecated - const file = await fetch(this.localesDirectory + formattedLocale + '/messages.json'); - return await file.json(); + super(systemLanguage, BrowserApi.isSafariApi ? 'safari' : null, async (formattedLocale: string) => { + if (BrowserApi.isSafariApi) { + await SafariApp.sendMessageToApp('getLocaleStrings', formattedLocale); + return (window as any).bitwardenLocaleStrings; + } else { + // Deprecated + const file = await fetch(this.localesDirectory + formattedLocale + '/messages.json'); + return await file.json(); + } }); this.supportedTranslationLocales = [