diff --git a/.gitignore b/.gitignore index ec4ccf74ded..ba56421cb76 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ focalboard*.db mac/resources/config.json mac/temp mac/dist +mac/*.xcodeproj/**/xcuserdata linux/bin linux/dist linux/temp diff --git a/mac/Focalboard/AppDelegate.swift b/mac/Focalboard/AppDelegate.swift index 8e86221893f..5c2440cd918 100644 --- a/mac/Focalboard/AppDelegate.swift +++ b/mac/Focalboard/AppDelegate.swift @@ -32,10 +32,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { @IBAction func openNewWindow(_ sender: Any?) { let mainStoryBoard = NSStoryboard(name: "Main", bundle: nil) - let tabViewController = mainStoryBoard.instantiateController(withIdentifier: "ViewController") as? ViewController let windowController = mainStoryBoard.instantiateController(withIdentifier: "WindowController") as! NSWindowController windowController.showWindow(self) - windowController.contentViewController = tabViewController } private func showWhatsNewDialogIfNeeded() { diff --git a/mac/Focalboard/ViewController.swift b/mac/Focalboard/ViewController.swift index 8bd5bfe9ee1..e1f1571208b 100644 --- a/mac/Focalboard/ViewController.swift +++ b/mac/Focalboard/ViewController.swift @@ -7,7 +7,8 @@ import WebKit class ViewController: NSViewController, WKUIDelegate, - WKNavigationDelegate { + WKNavigationDelegate, + WKScriptMessageHandler { @IBOutlet var webView: WKWebView! private var refreshWebViewOnLoad = true @@ -19,14 +20,15 @@ class ViewController: webView.navigationDelegate = self webView.uiDelegate = self webView.isHidden = true + webView.configuration.userContentController.add(self, name: "nativeApp") clearWebViewCache() // Load the home page if the server was started, otherwise wait until it has let appDelegate = NSApplication.shared.delegate as! AppDelegate if (appDelegate.isServerStarted) { - self.updateSessionToken() - self.loadHomepage() + updateSessionTokenAndUserSettings() + loadHomepage() } // Do any additional setup after loading the view. @@ -38,6 +40,11 @@ class ViewController: self.view.window?.makeFirstResponder(self.webView) } + override func viewWillDisappear() { + super.viewWillDisappear() + persistUserSettings() + } + override var representedObject: Any? { didSet { // Update the view, if already loaded. @@ -64,20 +71,55 @@ class ViewController: @objc func onServerStarted() { NSLog("onServerStarted") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.updateSessionToken() + self.updateSessionTokenAndUserSettings() self.loadHomepage() } } - private func updateSessionToken() { + private func persistUserSettings() { + let semaphore = DispatchSemaphore(value: 0) + + webView.evaluateJavaScript("Focalboard.exportUserSettingsBlob();") { result, error in + defer { semaphore.signal() } + guard let blob = result as? String else { + NSLog("Failed to export user settings: \(error?.localizedDescription ?? "?")") + return + } + UserDefaults.standard.set(blob, forKey: "localStorage") + NSLog("Persisted user settings: \(Data(base64Encoded: blob).flatMap { String(data: $0, encoding: .utf8) } ?? blob)") + } + + // During shutdown the system grants us about 5 seconds to clean up and store user data + let timeout = DispatchTime.now() + .seconds(3) + var result: DispatchTimeoutResult? + + // Busy wait because evaluateJavaScript can only be called from *and* signals on the main thread + while (result != .success && .now() < timeout) { + result = semaphore.wait(timeout: .now()) + RunLoop.current.run(mode: .default, before: Date()) + } + + if result == .timedOut { + NSLog("Timed out trying to persist user settings") + } + } + + private func updateSessionTokenAndUserSettings() { let appDelegate = NSApplication.shared.delegate as! AppDelegate - let script = WKUserScript( + let sessionTokenScript = WKUserScript( source: "localStorage.setItem('focalboardSessionId', '\(appDelegate.sessionToken)');", injectionTime: .atDocumentStart, forMainFrameOnly: true ) + let blob = UserDefaults.standard.string(forKey: "localStorage") ?? "" + let userSettingsScript = WKUserScript( + source: "const NativeApp = { settingsBlob: \"\(blob)\" };", + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) webView.configuration.userContentController.removeAllUserScripts() - webView.configuration.userContentController.addUserScript(script) + webView.configuration.userContentController.addUserScript(sessionTokenScript) + webView.configuration.userContentController.addUserScript(userSettingsScript) } private func loadHomepage() { @@ -219,5 +261,13 @@ class ViewController: @IBAction func navigateToHome(_ sender: NSObject) { loadHomepage() } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String: String], let type = body["type"], let blob = body["settingsBlob"] else { + NSLog("Received unexpected script message \(message.body)") + return + } + NSLog("Received script message \(type): \(Data(base64Encoded: blob).flatMap { String(data: $0, encoding: .utf8) } ?? blob)") + } } diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index adf38501942..3d3cb0122f1 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -19,8 +19,11 @@ import RegisterPage from './pages/registerPage' import {IUser} from './user' import {Utils} from './utils' import CombinedProviders from './combinedProviders' +import {importNativeAppSettings} from './nativeApp' const App = React.memo((): JSX.Element => { + importNativeAppSettings() + const [language, setLanguage] = useState(getCurrentLanguage()) const [user, setUser] = useState(undefined) const [initialLoad, setInitialLoad] = useState(false) diff --git a/webapp/src/nativeApp.ts b/webapp/src/nativeApp.ts new file mode 100644 index 00000000000..e46900b71ef --- /dev/null +++ b/webapp/src/nativeApp.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {importUserSettingsBlob} from './userSettings' + +declare interface INativeApp { + settingsBlob: string | null; +} + +declare const NativeApp: INativeApp + +export function importNativeAppSettings() { + if (typeof NativeApp === 'undefined' || !NativeApp.settingsBlob) { + return + } + const success = importUserSettingsBlob(NativeApp.settingsBlob) + const messageType = success ? 'didImportUserSettings' : 'didNotImportUserSettings' + postWebKitMessage({type: messageType, settingsBlob: NativeApp.settingsBlob}) + NativeApp.settingsBlob = null +} + +function postWebKitMessage(message: any) { + const webkit = (window as any).webkit + if (typeof webkit === 'undefined') { + return + } + const handler = webkit.messageHandlers.nativeApp + if (typeof handler === 'undefined') { + return + } + handler.postMessage(message) +} diff --git a/webapp/src/userSettings.ts b/webapp/src/userSettings.ts index 55126f1c983..188fcb072aa 100644 --- a/webapp/src/userSettings.ts +++ b/webapp/src/userSettings.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -class UserSettings { +export class UserSettings { static get prefillRandomIcons(): boolean { return localStorage.getItem('randomIcons') !== 'false' } @@ -11,4 +11,39 @@ class UserSettings { } } -export {UserSettings} +const keys = ['language', 'theme', 'lastBoardId', 'lastViewId', 'emoji-mart.last', 'emoji-mart.frequently'] + +export function exportUserSettingsBlob(): string { + return window.btoa(exportUserSettings()) +} + +function exportUserSettings(): string { + const settings = Object.fromEntries(keys.map((key) => [key, localStorage.getItem(key)])) + settings.timestamp = `${Date.now()}` + return JSON.stringify(settings) +} + +export function importUserSettingsBlob(blob: string): boolean { + return importUserSettings(window.atob(blob)) +} + +function importUserSettings(json: string): boolean { + const settings = parseUserSettings(json) + const timestamp = settings.timestamp + const lastTimestamp = localStorage.getItem('timestamp') + if (!timestamp || (lastTimestamp && Number(timestamp) <= Number(lastTimestamp))) { + return false + } + for (const [key, value] of Object.entries(settings)) { + localStorage.setItem(key, value as string) + } + return true +} + +function parseUserSettings(json: string): any { + try { + return JSON.parse(json) + } catch (e) { + return {} + } +} diff --git a/webapp/webpack.common.js b/webapp/webpack.common.js index 8214f8ca2a4..0503e2c9061 100644 --- a/webapp/webpack.common.js +++ b/webapp/webpack.common.js @@ -92,10 +92,9 @@ function makeCommonConfig() { publicPath: '{{.BaseURL}}/', }), ], - entry: { - main: './src/main.tsx', - }, + entry: ['./src/main.tsx', './src/userSettings.ts'], output: { + library: 'Focalboard', filename: 'static/[name].js', path: outpath, },